Merge branch 'master' into issues/2752

This commit is contained in:
Roman Zabaluev 2023-05-09 12:52:08 +04:00 committed by GitHub
commit af9bd198f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 331 additions and 128 deletions

View file

@ -12,7 +12,7 @@
<artifactId>kafka-ui-api</artifactId> <artifactId>kafka-ui-api</artifactId>
<properties> <properties>
<jacoco.version>0.8.8</jacoco.version> <jacoco.version>0.8.10</jacoco.version>
<sonar.java.coveragePlugin>jacoco</sonar.java.coveragePlugin> <sonar.java.coveragePlugin>jacoco</sonar.java.coveragePlugin>
<sonar.dynamicAnalysis>reuseReports</sonar.dynamicAnalysis> <sonar.dynamicAnalysis>reuseReports</sonar.dynamicAnalysis>
<sonar.jacoco.reportPath>${project.basedir}/target/jacoco.exec</sonar.jacoco.reportPath> <sonar.jacoco.reportPath>${project.basedir}/target/jacoco.exec</sonar.jacoco.reportPath>

View file

@ -1,7 +1,7 @@
package com.provectus.kafka.ui.model; package com.provectus.kafka.ui.model;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.MathContext; import java.math.RoundingMode;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@ -21,8 +21,6 @@ public class PartitionDistributionStats {
// avg skew will show unuseful results on low number of partitions // avg skew will show unuseful results on low number of partitions
private static final int MIN_PARTITIONS_FOR_SKEW_CALCULATION = 50; private static final int MIN_PARTITIONS_FOR_SKEW_CALCULATION = 50;
private static final MathContext ROUNDING_MATH_CTX = new MathContext(3);
private final Map<Node, Integer> partitionLeaders; private final Map<Node, Integer> partitionLeaders;
private final Map<Node, Integer> partitionsCount; private final Map<Node, Integer> partitionsCount;
private final Map<Node, Integer> inSyncPartitions; private final Map<Node, Integer> inSyncPartitions;
@ -88,6 +86,7 @@ public class PartitionDistributionStats {
return null; return null;
} }
value = value == null ? 0 : value; value = value == null ? 0 : value;
return new BigDecimal((value - avgValue) / avgValue * 100.0).round(ROUNDING_MATH_CTX); return new BigDecimal((value - avgValue) / avgValue * 100.0)
.setScale(1, RoundingMode.HALF_UP);
} }
} }

View file

@ -18,7 +18,7 @@
<httpcomponents.version>5.2.1</httpcomponents.version> <httpcomponents.version>5.2.1</httpcomponents.version>
<selenium.version>4.8.1</selenium.version> <selenium.version>4.8.1</selenium.version>
<selenide.version>6.12.3</selenide.version> <selenide.version>6.12.3</selenide.version>
<testng.version>7.7.0</testng.version> <testng.version>7.7.1</testng.version>
<allure.version>2.21.0</allure.version> <allure.version>2.21.0</allure.version>
<qase.io.version>3.0.4</qase.io.version> <qase.io.version>3.0.4</qase.io.version>
<aspectj.version>1.9.9.1</aspectj.version> <aspectj.version>1.9.9.1</aspectj.version>

View file

@ -48,7 +48,8 @@ public class BrokersList extends BasePage {
} }
private List<SelenideElement> getEnabledColumnHeaders() { private List<SelenideElement> getEnabledColumnHeaders() {
return Stream.of("Broker ID", "Segment Size", "Segment Count", "Port", "Host") return Stream.of("Broker ID", "Disk usage", "Partitions skew",
"Leaders", "Leader skew", "Online partitions", "Port", "Host")
.map(name -> $x(String.format(columnHeaderLocator, name))) .map(name -> $x(String.format(columnHeaderLocator, name)))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

View file

@ -17,11 +17,12 @@ import java.util.List;
public class KsqlQueryForm extends BasePage { public class KsqlQueryForm extends BasePage {
protected SelenideElement clearBtn = $x("//div/button[text()='Clear']"); protected SelenideElement clearBtn = $x("//div/button[text()='Clear']");
protected SelenideElement executeBtn = $x("//div/button[text()='Execute']"); protected SelenideElement executeBtn = $x("//div/button[text()='Execute']");
protected SelenideElement stopQueryBtn = $x("//div/button[text()='Stop query']");
protected SelenideElement clearResultsBtn = $x("//div/button[text()='Clear results']"); protected SelenideElement clearResultsBtn = $x("//div/button[text()='Clear results']");
protected SelenideElement addStreamPropertyBtn = $x("//button[text()='Add Stream Property']"); protected SelenideElement addStreamPropertyBtn = $x("//button[text()='Add Stream Property']");
protected SelenideElement queryAreaValue = $x("//div[@class='ace_content']"); protected SelenideElement queryAreaValue = $x("//div[@class='ace_content']");
protected SelenideElement queryArea = $x("//div[@id='ksql']/textarea[@class='ace_text-input']"); protected SelenideElement queryArea = $x("//div[@id='ksql']/textarea[@class='ace_text-input']");
protected SelenideElement abortButton = $x("//div[@role='status']/div[text()='Abort']");
protected SelenideElement cancelledAlert = $x("//div[@role='status'][text()='Cancelled']");
protected ElementsCollection ksqlGridItems = $$x("//tbody//tr"); protected ElementsCollection ksqlGridItems = $$x("//tbody//tr");
protected ElementsCollection keyField = $$x("//input[@aria-label='key']"); protected ElementsCollection keyField = $$x("//input[@aria-label='key']");
protected ElementsCollection valueField = $$x("//input[@aria-label='value']"); protected ElementsCollection valueField = $$x("//input[@aria-label='value']");
@ -48,7 +49,7 @@ public class KsqlQueryForm extends BasePage {
public KsqlQueryForm clickExecuteBtn(String query) { public KsqlQueryForm clickExecuteBtn(String query) {
clickByActions(executeBtn); clickByActions(executeBtn);
if (query.contains("EMIT CHANGES")) { if (query.contains("EMIT CHANGES")) {
loadingSpinner.shouldBe(Condition.visible); abortButton.shouldBe(Condition.visible);
} else { } else {
waitUntilSpinnerDisappear(); waitUntilSpinnerDisappear();
} }
@ -56,12 +57,21 @@ public class KsqlQueryForm extends BasePage {
} }
@Step @Step
public KsqlQueryForm clickStopQueryBtn() { public boolean isAbortBtnVisible() {
clickByActions(stopQueryBtn); return isVisible(abortButton);
waitUntilSpinnerDisappear(); }
@Step
public KsqlQueryForm clickAbortBtn() {
clickByActions(abortButton);
return this; return this;
} }
@Step
public boolean isCancelledAlertVisible() {
return isVisible(cancelledAlert);
}
@Step @Step
public KsqlQueryForm clickClearResultsBtn() { public KsqlQueryForm clickClearResultsBtn() {
clickByActions(clearResultsBtn); clickByActions(clearResultsBtn);

View file

@ -22,72 +22,65 @@ public class SmokeBacklog extends BaseManualTest {
} }
@Automation(state = TO_BE_AUTOMATED) @Automation(state = TO_BE_AUTOMATED)
@Suite(id = KSQL_DB_SUITE_ID) @Suite(id = BROKERS_SUITE_ID)
@QaseId(277) @QaseId(331)
@Test @Test
public void testCaseB() { public void testCaseB() {
} }
@Automation(state = TO_BE_AUTOMATED)
@Suite(id = BROKERS_SUITE_ID)
@QaseId(331)
@Test
public void testCaseC() {
}
@Automation(state = TO_BE_AUTOMATED) @Automation(state = TO_BE_AUTOMATED)
@Suite(id = BROKERS_SUITE_ID) @Suite(id = BROKERS_SUITE_ID)
@QaseId(332) @QaseId(332)
@Test @Test
public void testCaseD() { public void testCaseC() {
} }
@Automation(state = TO_BE_AUTOMATED) @Automation(state = TO_BE_AUTOMATED)
@Suite(id = TOPICS_PROFILE_SUITE_ID) @Suite(id = TOPICS_PROFILE_SUITE_ID)
@QaseId(335) @QaseId(335)
@Test @Test
public void testCaseE() { public void testCaseD() {
} }
@Automation(state = TO_BE_AUTOMATED) @Automation(state = TO_BE_AUTOMATED)
@Suite(id = TOPICS_PROFILE_SUITE_ID) @Suite(id = TOPICS_PROFILE_SUITE_ID)
@QaseId(336) @QaseId(336)
@Test @Test
public void testCaseF() { public void testCaseE() {
} }
@Automation(state = TO_BE_AUTOMATED) @Automation(state = TO_BE_AUTOMATED)
@Suite(id = TOPICS_PROFILE_SUITE_ID) @Suite(id = TOPICS_PROFILE_SUITE_ID)
@QaseId(343) @QaseId(343)
@Test @Test
public void testCaseG() { public void testCaseF() {
} }
@Automation(state = TO_BE_AUTOMATED) @Automation(state = TO_BE_AUTOMATED)
@Suite(id = KSQL_DB_SUITE_ID) @Suite(id = KSQL_DB_SUITE_ID)
@QaseId(344) @QaseId(344)
@Test @Test
public void testCaseH() { public void testCaseG() {
} }
@Automation(state = TO_BE_AUTOMATED) @Automation(state = TO_BE_AUTOMATED)
@Suite(id = SCHEMAS_SUITE_ID) @Suite(id = SCHEMAS_SUITE_ID)
@QaseId(345) @QaseId(345)
@Test @Test
public void testCaseI() { public void testCaseH() {
} }
@Automation(state = TO_BE_AUTOMATED) @Automation(state = TO_BE_AUTOMATED)
@Suite(id = SCHEMAS_SUITE_ID) @Suite(id = SCHEMAS_SUITE_ID)
@QaseId(346) @QaseId(346)
@Test @Test
public void testCaseJ() { public void testCaseI() {
} }
@Automation(state = TO_BE_AUTOMATED) @Automation(state = TO_BE_AUTOMATED)
@Suite(id = TOPICS_PROFILE_SUITE_ID) @Suite(id = TOPICS_PROFILE_SUITE_ID)
@QaseId(347) @QaseId(347)
@Test @Test
public void testCaseK() { public void testCaseJ() {
} }
} }

View file

@ -1,6 +1,7 @@
package com.provectus.kafka.ui.smokesuite.ksqldb; package com.provectus.kafka.ui.smokesuite.ksqldb;
import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlMenuTabs.STREAMS; import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlMenuTabs.STREAMS;
import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlQueryConfig.SELECT_ALL_FROM;
import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlQueryConfig.SHOW_STREAMS; import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlQueryConfig.SHOW_STREAMS;
import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlQueryConfig.SHOW_TABLES; import static com.provectus.kafka.ui.pages.ksqldb.enums.KsqlQueryConfig.SHOW_TABLES;
import static com.provectus.kafka.ui.pages.panels.enums.MenuItem.KSQL_DB; import static com.provectus.kafka.ui.pages.panels.enums.MenuItem.KSQL_DB;
@ -87,7 +88,8 @@ public class KsqlDbTest extends BaseTest {
navigateToKsqlDbAndExecuteRequest(SHOW_STREAMS.getQuery()); navigateToKsqlDbAndExecuteRequest(SHOW_STREAMS.getQuery());
SoftAssert softly = new SoftAssert(); SoftAssert softly = new SoftAssert();
softly.assertTrue(ksqlQueryForm.areResultsVisible(), "areResultsVisible()"); softly.assertTrue(ksqlQueryForm.areResultsVisible(), "areResultsVisible()");
softly.assertTrue(ksqlQueryForm.getItemByName(DEFAULT_STREAM.getName()).isVisible(), "getItemByName()"); softly.assertTrue(ksqlQueryForm.getItemByName(DEFAULT_STREAM.getName()).isVisible(),
String.format("getItemByName(%s)", FIRST_TABLE.getName()));
softly.assertAll(); softly.assertAll();
} }
@ -104,6 +106,16 @@ public class KsqlDbTest extends BaseTest {
softly.assertAll(); softly.assertAll();
} }
@QaseId(277)
@Test(priority = 6)
public void stopQueryFunctionalCheck() {
navigateToKsqlDbAndExecuteRequest(String.format(SELECT_ALL_FROM.getQuery(), FIRST_TABLE.getName()));
Assert.assertTrue(ksqlQueryForm.isAbortBtnVisible(), "isAbortBtnVisible()");
ksqlQueryForm
.clickAbortBtn();
Assert.assertTrue(ksqlQueryForm.isCancelledAlertVisible(), "isCancelledAlertVisible()");
}
@AfterClass(alwaysRun = true) @AfterClass(alwaysRun = true)
public void afterClass() { public void afterClass() {
TOPIC_NAMES_LIST.forEach(topicName -> apiService.deleteTopic(topicName)); TOPIC_NAMES_LIST.forEach(topicName -> apiService.deleteTopic(topicName));

View file

@ -11,7 +11,9 @@ import CheckMarkRoundIcon from 'components/common/Icons/CheckMarkRoundIcon';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { clusterBrokerPath } from 'lib/paths'; import { clusterBrokerPath } from 'lib/paths';
import Tooltip from 'components/common/Tooltip/Tooltip'; import Tooltip from 'components/common/Tooltip/Tooltip';
import ColoredCell from 'components/common/NewTable/ColoredCell';
import SkewHeader from './SkewHeader/SkewHeader';
import * as S from './BrokersList.styled'; import * as S from './BrokersList.styled';
const NA = 'N/A'; const NA = 'N/A';
@ -57,11 +59,15 @@ const BrokersList: React.FC = () => {
count: segmentCount || NA, count: segmentCount || NA,
port: broker?.port, port: broker?.port,
host: broker?.host, host: broker?.host,
partitionsLeader: broker?.partitionsLeader,
partitionsSkew: broker?.partitionsSkew,
leadersSkew: broker?.leadersSkew,
inSyncPartitions: broker?.inSyncPartitions,
}; };
}); });
}, [diskUsage, brokers]); }, [diskUsage, brokers]);
const columns = React.useMemo<ColumnDef<typeof rows>[]>( const columns = React.useMemo<ColumnDef<(typeof rows)[number]>[]>(
() => [ () => [
{ {
header: 'Broker ID', header: 'Broker ID',
@ -84,7 +90,7 @@ const BrokersList: React.FC = () => {
), ),
}, },
{ {
header: 'Segment Size', header: 'Disk usage',
accessorKey: 'size', accessorKey: 'size',
// eslint-disable-next-line react/no-unstable-nested-components // eslint-disable-next-line react/no-unstable-nested-components
cell: ({ getValue, table, cell, column, renderValue, row }) => cell: ({ getValue, table, cell, column, renderValue, row }) =>
@ -98,10 +104,56 @@ const BrokersList: React.FC = () => {
cell={cell} cell={cell}
getValue={getValue} getValue={getValue}
renderValue={renderValue} renderValue={renderValue}
renderSegments
/> />
), ),
}, },
{ header: 'Segment Count', accessorKey: 'count' }, {
// eslint-disable-next-line react/no-unstable-nested-components
header: () => <SkewHeader />,
accessorKey: 'partitionsSkew',
// eslint-disable-next-line react/no-unstable-nested-components
cell: ({ getValue }) => {
const value = getValue<number>();
return (
<ColoredCell
value={value ? `${value.toFixed(2)}%` : '-'}
warn={value >= 10 && value < 20}
attention={value >= 20}
/>
);
},
},
{ header: 'Leaders', accessorKey: 'partitionsLeader' },
{
header: 'Leader skew',
accessorKey: 'leadersSkew',
// eslint-disable-next-line react/no-unstable-nested-components
cell: ({ getValue }) => {
const value = getValue<number>();
return (
<ColoredCell
value={value ? `${value.toFixed(2)}%` : '-'}
warn={value >= 10 && value < 20}
attention={value >= 20}
/>
);
},
},
{
header: 'Online partitions',
accessorKey: 'inSyncPartitions',
// eslint-disable-next-line react/no-unstable-nested-components
cell: ({ getValue, row }) => {
const value = getValue<number>();
return (
<ColoredCell
value={value}
attention={value !== row.original.count}
/>
);
},
},
{ header: 'Port', accessorKey: 'port' }, { header: 'Port', accessorKey: 'port' },
{ {
header: 'Host', header: 'Host',

View file

@ -0,0 +1,11 @@
import styled from 'styled-components';
import { MessageTooltip } from 'components/common/Tooltip/Tooltip.styled';
export const CellWrapper = styled.div`
display: flex;
gap: 10px;
${MessageTooltip} {
max-height: unset;
}
`;

View file

@ -0,0 +1,17 @@
import React from 'react';
import Tooltip from 'components/common/Tooltip/Tooltip';
import InfoIcon from 'components/common/Icons/InfoIcon';
import * as S from './SkewHeader.styled';
const SkewHeader: React.FC = () => (
<S.CellWrapper>
Partitions skew
<Tooltip
value={<InfoIcon />}
content="The divergence from the average brokers' value"
/>
</S.CellWrapper>
);
export default SkewHeader;

View file

@ -37,7 +37,7 @@ const Config: React.FC = () => {
formState: { isDirty, isSubmitting, isValid, errors }, formState: { isDirty, isSubmitting, isValid, errors },
setValue, setValue,
} = useForm<FormValues>({ } = useForm<FormValues>({
mode: 'onTouched', mode: 'onChange',
resolver: yupResolver(validationSchema), resolver: yupResolver(validationSchema),
defaultValues: { defaultValues: {
config: JSON.stringify(config, null, '\t'), config: JSON.stringify(config, null, '\t'),

View file

@ -1,17 +1,31 @@
import React from 'react'; import React, { useMemo } from 'react';
import { Cluster } from 'generated-sources'; import { Cluster, ResourceType } from 'generated-sources';
import { CellContext } from '@tanstack/react-table'; import { CellContext } from '@tanstack/react-table';
import { Button } from 'components/common/Button/Button';
import { clusterConfigPath } from 'lib/paths'; import { clusterConfigPath } from 'lib/paths';
import { useGetUserInfo } from 'lib/hooks/api/roles';
import { ActionCanButton } from 'components/common/ActionComponent';
type Props = CellContext<Cluster, unknown>; type Props = CellContext<Cluster, unknown>;
const ClusterTableActionsCell: React.FC<Props> = ({ row }) => { const ClusterTableActionsCell: React.FC<Props> = ({ row }) => {
const { name } = row.original; const { name } = row.original;
const { data } = useGetUserInfo();
const isApplicationConfig = useMemo(() => {
return !!data?.userInfo?.permissions.some(
(permission) => permission.resource === ResourceType.APPLICATIONCONFIG
);
}, [data]);
return ( return (
<Button buttonType="secondary" buttonSize="S" to={clusterConfigPath(name)}> <ActionCanButton
buttonType="secondary"
buttonSize="S"
to={clusterConfigPath(name)}
canDoAction={isApplicationConfig}
>
Configure Configure
</Button> </ActionCanButton>
); );
}; };

View file

@ -1,23 +1,25 @@
import React, { useEffect } from 'react'; import React, { useEffect, useMemo } from 'react';
import PageHeading from 'components/common/PageHeading/PageHeading'; import PageHeading from 'components/common/PageHeading/PageHeading';
import * as Metrics from 'components/common/Metrics'; import * as Metrics from 'components/common/Metrics';
import { Tag } from 'components/common/Tag/Tag.styled'; import { Tag } from 'components/common/Tag/Tag.styled';
import Switch from 'components/common/Switch/Switch'; import Switch from 'components/common/Switch/Switch';
import { useClusters } from 'lib/hooks/api/clusters'; import { useClusters } from 'lib/hooks/api/clusters';
import { Cluster, ServerStatus } from 'generated-sources'; import { Cluster, ResourceType, ServerStatus } from 'generated-sources';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import Table, { SizeCell } from 'components/common/NewTable'; import Table, { SizeCell } from 'components/common/NewTable';
import useBoolean from 'lib/hooks/useBoolean'; import useBoolean from 'lib/hooks/useBoolean';
import { Button } from 'components/common/Button/Button';
import { clusterNewConfigPath } from 'lib/paths'; import { clusterNewConfigPath } from 'lib/paths';
import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext'; import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ActionCanButton } from 'components/common/ActionComponent';
import { useGetUserInfo } from 'lib/hooks/api/roles';
import * as S from './Dashboard.styled'; import * as S from './Dashboard.styled';
import ClusterName from './ClusterName'; import ClusterName from './ClusterName';
import ClusterTableActionsCell from './ClusterTableActionsCell'; import ClusterTableActionsCell from './ClusterTableActionsCell';
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const { data } = useGetUserInfo();
const clusters = useClusters(); const clusters = useClusters();
const { value: showOfflineOnly, toggle } = useBoolean(false); const { value: showOfflineOnly, toggle } = useBoolean(false);
const appInfo = React.useContext(GlobalSettingsContext); const appInfo = React.useContext(GlobalSettingsContext);
@ -62,6 +64,11 @@ const Dashboard: React.FC = () => {
} }
}, [clusters, appInfo.hasDynamicConfig]); }, [clusters, appInfo.hasDynamicConfig]);
const isApplicationConfig = useMemo(() => {
return !!data?.userInfo?.permissions.some(
(permission) => permission.resource === ResourceType.APPLICATIONCONFIG
);
}, [data]);
return ( return (
<> <>
<PageHeading text="Dashboard" /> <PageHeading text="Dashboard" />
@ -87,9 +94,14 @@ const Dashboard: React.FC = () => {
<label>Only offline clusters</label> <label>Only offline clusters</label>
</div> </div>
{appInfo.hasDynamicConfig && ( {appInfo.hasDynamicConfig && (
<Button buttonType="primary" buttonSize="M" to={clusterNewConfigPath}> <ActionCanButton
buttonType="primary"
buttonSize="M"
to={clusterNewConfigPath}
canDoAction={isApplicationConfig}
>
Configure new cluster Configure new cluster
</Button> </ActionCanButton>
)} )}
</S.Toolbar> </S.Toolbar>
<Table <Table

View file

@ -55,7 +55,7 @@ const Form: React.FC = () => {
yup.object().shape({ yup.object().shape({
newSchema: newSchema:
schema?.schemaType === SchemaType.PROTOBUF schema?.schemaType === SchemaType.PROTOBUF
? yup.string().required().isEnum('Schema syntax is not valid') ? yup.string().required()
: yup.string().required().isJsonObject('Schema syntax is not valid'), : yup.string().required().isJsonObject('Schema syntax is not valid'),
}); });
const methods = useForm<NewSchemaSubjectRaw>({ const methods = useForm<NewSchemaSubjectRaw>({

View file

@ -8,6 +8,7 @@ import { formatTimestamp } from 'lib/dateTimeHelpers';
import { JSONPath } from 'jsonpath-plus'; import { JSONPath } from 'jsonpath-plus';
import Ellipsis from 'components/common/Ellipsis/Ellipsis'; import Ellipsis from 'components/common/Ellipsis/Ellipsis';
import WarningRedIcon from 'components/common/Icons/WarningRedIcon'; import WarningRedIcon from 'components/common/Icons/WarningRedIcon';
import Tooltip from 'components/common/Tooltip/Tooltip';
import MessageContent from './MessageContent/MessageContent'; import MessageContent from './MessageContent/MessageContent';
import * as S from './MessageContent/MessageContent.styled'; import * as S from './MessageContent/MessageContent.styled';
@ -110,14 +111,26 @@ const Message: React.FC<Props> = ({
</td> </td>
<S.DataCell title={key}> <S.DataCell title={key}>
<Ellipsis text={renderFilteredJson(key, keyFilters)}> <Ellipsis text={renderFilteredJson(key, keyFilters)}>
{keySerde === 'Fallback' && <WarningRedIcon />} {keySerde === 'Fallback' && (
<Tooltip
value={<WarningRedIcon />}
content="Fallback serde was used"
placement="left"
/>
)}
</Ellipsis> </Ellipsis>
</S.DataCell> </S.DataCell>
<S.DataCell title={content}> <S.DataCell title={content}>
<S.Metadata> <S.Metadata>
<S.MetadataValue> <S.MetadataValue>
<Ellipsis text={renderFilteredJson(content, contentFilters)}> <Ellipsis text={renderFilteredJson(content, contentFilters)}>
{valueSerde === 'Fallback' && <WarningRedIcon />} {valueSerde === 'Fallback' && (
<Tooltip
value={<WarningRedIcon />}
content="Fallback serde was used"
placement="left"
/>
)}
</Ellipsis> </Ellipsis>
</S.MetadataValue> </S.MetadataValue>
</S.Metadata> </S.Metadata>

View file

@ -210,6 +210,7 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({
name={name} name={name}
onChange={onChange} onChange={onChange}
value={value} value={value}
height="40px"
/> />
)} )}
/> />
@ -225,6 +226,7 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({
name={name} name={name}
onChange={onChange} onChange={onChange}
value={value} value={value}
height="280px"
/> />
)} )}
/> />
@ -242,7 +244,7 @@ const SendMessage: React.FC<{ closeSidebar: () => void }> = ({
defaultValue="{}" defaultValue="{}"
name={name} name={name}
onChange={onChange} onChange={onChange}
height="200px" height="40px"
/> />
)} )}
/> />

View file

@ -29,6 +29,16 @@ export const Wrapper = styled.div`
width: 16px; width: 16px;
fill: ${({ theme }) => theme.input.icon.color}; fill: ${({ theme }) => theme.input.icon.color};
} }
svg:last-child {
position: absolute;
top: 8px;
line-height: 0;
z-index: 1;
left: unset;
right: 12px;
height: 16px;
width: 16px;
}
`; `;
export const Input = styled.input<InputProps>( export const Input = styled.input<InputProps>(

View file

@ -16,6 +16,7 @@ export interface InputProps
withError?: boolean; withError?: boolean;
label?: React.ReactNode; label?: React.ReactNode;
hint?: React.ReactNode; hint?: React.ReactNode;
clearIcon?: React.ReactNode;
// Some may only accept integer, like `Number of Partitions` // Some may only accept integer, like `Number of Partitions`
// some may accept decimal // some may accept decimal
@ -99,19 +100,22 @@ function pasteNumberCheck(
return value; return value;
} }
const Input: React.FC<InputProps> = ({ const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) => {
name, const {
hookFormOptions, name,
search, hookFormOptions,
inputSize = 'L', search,
type, inputSize = 'L',
positiveOnly, type,
integerOnly, positiveOnly,
withError = false, integerOnly,
label, withError = false,
hint, label,
...rest hint,
}) => { clearIcon,
...rest
} = props;
const methods = useFormContext(); const methods = useFormContext();
const fieldId = React.useId(); const fieldId = React.useId();
@ -168,7 +172,6 @@ const Input: React.FC<InputProps> = ({
// if the field is a part of react-hook-form form // if the field is a part of react-hook-form form
inputOptions = { ...rest, ...methods.register(name, hookFormOptions) }; inputOptions = { ...rest, ...methods.register(name, hookFormOptions) };
} }
return ( return (
<div> <div>
{label && <InputLabel htmlFor={rest.id || fieldId}>{label}</InputLabel>} {label && <InputLabel htmlFor={rest.id || fieldId}>{label}</InputLabel>}
@ -181,8 +184,11 @@ const Input: React.FC<InputProps> = ({
type={type} type={type}
onKeyPress={keyPressEventHandler} onKeyPress={keyPressEventHandler}
onPaste={pasteEventHandler} onPaste={pasteEventHandler}
ref={ref}
{...inputOptions} {...inputOptions}
/> />
{clearIcon}
{withError && isHookFormField && ( {withError && isHookFormField && (
<S.FormError> <S.FormError>
<ErrorMessage name={name} /> <ErrorMessage name={name} />
@ -192,6 +198,6 @@ const Input: React.FC<InputProps> = ({
</S.Wrapper> </S.Wrapper>
</div> </div>
); );
}; });
export default Input; export default Input;

View file

@ -0,0 +1,41 @@
import React from 'react';
import styled from 'styled-components';
interface CellProps {
isWarning?: boolean;
isAttention?: boolean;
}
interface ColoredCellProps {
value: number | string;
warn?: boolean;
attention?: boolean;
}
const Cell = styled.div<CellProps>`
color: ${(props) => {
if (props.isAttention) {
return props.theme.table.colored.color.attention;
}
if (props.isWarning) {
return props.theme.table.colored.color.warning;
}
return 'inherit';
}};
`;
const ColoredCell: React.FC<ColoredCellProps> = ({
value,
warn,
attention,
}) => {
return (
<Cell isWarning={warn} isAttention={attention}>
{value}
</Cell>
);
};
export default ColoredCell;

View file

@ -3,8 +3,15 @@ import { CellContext } from '@tanstack/react-table';
import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted'; import BytesFormatted from 'components/common/BytesFormatted/BytesFormatted';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const SizeCell: React.FC<CellContext<any, unknown>> = ({ getValue }) => ( type AsAny = any;
<BytesFormatted value={getValue<string | number>()} />
const SizeCell: React.FC<
CellContext<AsAny, unknown> & { renderSegments?: boolean }
> = ({ getValue, row, renderSegments = false }) => (
<>
<BytesFormatted value={getValue<string | number>()} />
{renderSegments ? `, ${row?.original.count} segment(s)` : null}
</>
); );
export default SizeCell; export default SizeCell;

View file

@ -1,7 +1,9 @@
import React from 'react'; import React, { useRef } from 'react';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import Input from 'components/common/Input/Input'; import Input from 'components/common/Input/Input';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import CloseIcon from 'components/common/Icons/CloseIcon';
import styled from 'styled-components';
interface SearchProps { interface SearchProps {
placeholder?: string; placeholder?: string;
@ -10,6 +12,16 @@ interface SearchProps {
value?: string; value?: string;
} }
const IconButtonWrapper = styled.span.attrs(() => ({
role: 'button',
tabIndex: '0',
}))`
height: 16px !important;
display: inline-block;
&:hover {
cursor: pointer;
}
`;
const Search: React.FC<SearchProps> = ({ const Search: React.FC<SearchProps> = ({
placeholder = 'Search', placeholder = 'Search',
disabled = false, disabled = false,
@ -17,7 +29,11 @@ const Search: React.FC<SearchProps> = ({
onChange, onChange,
}) => { }) => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const ref = useRef<HTMLInputElement>(null);
const handleChange = useDebouncedCallback((e) => { const handleChange = useDebouncedCallback((e) => {
if (ref.current != null) {
ref.current.value = e.target.value;
}
if (onChange) { if (onChange) {
onChange(e.target.value); onChange(e.target.value);
} else { } else {
@ -28,6 +44,15 @@ const Search: React.FC<SearchProps> = ({
setSearchParams(searchParams); setSearchParams(searchParams);
} }
}, 500); }, 500);
const clearSearchValue = () => {
if (searchParams.get('q')) {
searchParams.set('q', '');
setSearchParams(searchParams);
}
if (ref.current != null) {
ref.current.value = '';
}
};
return ( return (
<Input <Input
@ -37,7 +62,13 @@ const Search: React.FC<SearchProps> = ({
defaultValue={value || searchParams.get('q') || ''} defaultValue={value || searchParams.get('q') || ''}
inputSize="M" inputSize="M"
disabled={disabled} disabled={disabled}
ref={ref}
search search
clearIcon={
<IconButtonWrapper onClick={clearSearchValue}>
<CloseIcon />
</IconButtonWrapper>
}
/> />
); );
}; };

View file

@ -41,4 +41,24 @@ describe('Search', () => {
render(<Search />); render(<Search />);
expect(screen.queryByPlaceholderText('Search')).toBeInTheDocument(); expect(screen.queryByPlaceholderText('Search')).toBeInTheDocument();
}); });
it('Clear button is visible', () => {
render(<Search placeholder={placeholder} />);
const clearButton = screen.getByRole('button');
expect(clearButton).toBeInTheDocument();
});
it('Clear button should clear text from input', async () => {
render(<Search placeholder={placeholder} />);
const searchField = screen.getAllByRole('textbox')[0];
await userEvent.type(searchField, 'some text');
expect(searchField).toHaveValue('some text');
const clearButton = screen.getByRole('button');
await userEvent.click(clearButton);
expect(searchField).toHaveValue('');
});
}); });

View file

@ -1,19 +1,5 @@
import { isValidEnum, isValidJsonObject } from 'lib/yupExtended'; import { isValidJsonObject } from 'lib/yupExtended';
const invalidEnum = `
ennum SchemType {
AVRO = 0;
JSON = 1;
PROTOBUF = 3;
}
`;
const validEnum = `
enum SchemType {
AVRO = 0;
JSON = 1;
PROTOBUF = 3;
}
`;
describe('yup extended', () => { describe('yup extended', () => {
describe('isValidJsonObject', () => { describe('isValidJsonObject', () => {
it('returns false for no value', () => { it('returns false for no value', () => {
@ -35,21 +21,4 @@ describe('yup extended', () => {
expect(isValidJsonObject('{ "foo": "bar" }')).toBeTruthy(); expect(isValidJsonObject('{ "foo": "bar" }')).toBeTruthy();
}); });
}); });
describe('isValidEnum', () => {
it('returns false for invalid enum', () => {
expect(isValidEnum(invalidEnum)).toBeFalsy();
});
it('returns false for no value', () => {
expect(isValidEnum()).toBeFalsy();
});
it('returns true should trim value', () => {
expect(
isValidEnum(` enum SchemType {AVRO = 0; PROTOBUF = 3;} `)
).toBeTruthy();
});
it('returns true for valid enum', () => {
expect(isValidEnum(validEnum)).toBeTruthy();
});
});
}); });

View file

@ -1,6 +1,6 @@
export const formatTimestamp = ( export const formatTimestamp = (
timestamp?: number | string | Date, timestamp?: number | string | Date,
format: Intl.DateTimeFormatOptions = { hour12: false } format: Intl.DateTimeFormatOptions = { hourCycle: 'h23' }
): string => { ): string => {
if (!timestamp) { if (!timestamp) {
return ''; return '';
@ -8,7 +8,6 @@ export const formatTimestamp = (
// empty array gets the default one from the browser // empty array gets the default one from the browser
const date = new Date(timestamp); const date = new Date(timestamp);
// invalid date // invalid date
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return ''; return '';

View file

@ -10,7 +10,6 @@ declare module 'yup' {
TFlags extends yup.Flags = '' TFlags extends yup.Flags = ''
> extends yup.Schema<TType, TContext, TDefault, TFlags> { > extends yup.Schema<TType, TContext, TDefault, TFlags> {
isJsonObject(message?: string): StringSchema<TType, TContext>; isJsonObject(message?: string): StringSchema<TType, TContext>;
isEnum(message?: string): StringSchema<TType, TContext>;
} }
} }
@ -40,32 +39,6 @@ const isJsonObject = (message?: string) => {
isValidJsonObject isValidJsonObject
); );
}; };
export const isValidEnum = (value?: string) => {
try {
if (!value) return false;
const trimmedValue = value.trim();
if (
trimmedValue.indexOf('enum') === 0 &&
trimmedValue.lastIndexOf('}') === trimmedValue.length - 1
) {
return true;
}
} catch {
// do nothing
}
return false;
};
const isEnum = (message?: string) => {
return yup.string().test(
'isEnum',
// eslint-disable-next-line no-template-curly-in-string
message || '${path} is not Enum object',
isValidEnum
);
};
/** /**
* due to yup rerunning all the object validiation during any render, * due to yup rerunning all the object validiation during any render,
* it makes sense to cache the async results * it makes sense to cache the async results
@ -88,7 +61,6 @@ export function cacheTest(
} }
yup.addMethod(yup.StringSchema, 'isJsonObject', isJsonObject); yup.addMethod(yup.StringSchema, 'isJsonObject', isJsonObject);
yup.addMethod(yup.StringSchema, 'isEnum', isEnum);
export const topicFormValidationSchema = yup.object().shape({ export const topicFormValidationSchema = yup.object().shape({
name: yup name: yup

View file

@ -533,6 +533,12 @@ export const theme = {
active: Colors.neutral[90], active: Colors.neutral[90],
}, },
}, },
colored: {
color: {
attention: Colors.red[50],
warning: Colors.yellow[20],
},
},
expander: { expander: {
normal: Colors.brand[30], normal: Colors.brand[30],
hover: Colors.brand[40], hover: Colors.brand[40],
@ -928,6 +934,12 @@ export const darkTheme: ThemeType = {
active: Colors.neutral[0], active: Colors.neutral[0],
}, },
}, },
colored: {
color: {
attention: Colors.red[50],
warning: Colors.yellow[20],
},
},
expander: { expander: {
normal: Colors.brand[30], normal: Colors.brand[30],
hover: Colors.brand[40], hover: Colors.brand[40],