feat: add logs to monitor (#149)

* feat: add logs to cluster's monitor

* feat: add logs to node's monitor

* chore: update release notes

---------

Co-authored-by: yaojiping <yaojiping@infini.ltd>
This commit is contained in:
yaojp123 2025-02-19 14:48:49 +08:00 committed by GitHub
parent 1e601f259b
commit 8452d8ef3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 665 additions and 7 deletions

View File

@ -13,6 +13,8 @@ Information about release notes of INFINI Console is provided here.
### Features ### Features
- Add Logs to Monitor (cluster, node)
### Bug fix ### Bug fix
- Fixed the error when querying empty metric data (#144) - Fixed the error when querying empty metric data (#144)
- Fixed empty host when setup step finishes (#147) - Fixed empty host when setup step finishes (#147)

View File

@ -13,6 +13,8 @@ title: "版本历史"
### Features ### Features
- 监控(集群、节点)新增日志查询
### Bug fix ### Bug fix
- 修复指标数据为空时的查询错误 (#144) - 修复指标数据为空时的查询错误 (#144)
- 修复初始化结束步骤中主机显示为错误的问题 (#147) - 修复初始化结束步骤中主机显示为错误的问题 (#147)

View File

@ -119,9 +119,10 @@ const Monitor = (props) => {
setParam({ ...param, timeRange: state.timeRange, timeInterval: state.timeInterval, timeout: state.timeout }); setParam({ ...param, timeRange: state.timeRange, timeInterval: state.timeInterval, timeout: state.timeout });
}, [state.timeRange, state.timeInterval, state.timeout]); }, [state.timeRange, state.timeInterval, state.timeout]);
const handleTimeChange = useCallback(({ start, end, timeInterval, timeout, refresh }) => { const handleTimeChange = ({ start, end, timeInterval, timeout, refresh }) => {
setState(initState({ setState(initState({
...state, ...state,
param,
timeRange: { timeRange: {
min: start, min: start,
max: end, max: end,
@ -130,11 +131,12 @@ const Monitor = (props) => {
timeout: timeout || state.timeout, timeout: timeout || state.timeout,
refresh refresh
})); }));
}, [state]) }
const onInfoChange = (info) => { const onInfoChange = (info) => {
setState({ setState({
...state, ...state,
param,
info, info,
}); });
}; };
@ -180,7 +182,7 @@ const Monitor = (props) => {
onTimeSettingsChange(newRefresh) onTimeSettingsChange(newRefresh)
setRefresh(newRefresh) setRefresh(newRefresh)
}} }}
onRefresh={handleTimeChange} onRefresh={(value) => handleTimeChange({ ...(value || {}), refresh: new Date().valueOf()})}
showTimeSetting={true} showTimeSetting={true}
showTimeInterval={true} showTimeInterval={true}
timeInterval={state.timeInterval} timeInterval={state.timeInterval}
@ -194,6 +196,7 @@ const Monitor = (props) => {
}) })
setState({ setState({
...state, ...state,
param,
timeInterval: timeSetting.timeInterval, timeInterval: timeSetting.timeInterval,
timeout: timeSetting.timeout timeout: timeSetting.timeout
}); });
@ -221,7 +224,7 @@ const Monitor = (props) => {
animated={false} animated={false}
> >
{panes.map((pane) => ( {panes.map((pane) => (
<TabPane tab={pane.title} key={pane.key}> <TabPane tab={formatMessage({id: `cluster.monitor.tabs.${pane.key}`})} key={pane.key}>
<Spin spinning={spinning && !!state.refresh}> <Spin spinning={spinning && !!state.refresh}>
<StatisticBar <StatisticBar
setSpinning={setSpinning} setSpinning={setSpinning}
@ -249,6 +252,7 @@ const Monitor = (props) => {
}) })
setState({ setState({
...state, ...state,
param,
timeInterval, timeInterval,
}); });
}} }}

View File

@ -124,6 +124,21 @@ export default {
"cluster.monitor.topn.color": "Color Metric", "cluster.monitor.topn.color": "Color Metric",
"cluster.monitor.topn.theme": "Theme", "cluster.monitor.topn.theme": "Theme",
"cluster.monitor.logs.timestamp": "Timestamp",
"cluster.monitor.logs.type": "Type",
"cluster.monitor.logs.level": "Level",
"cluster.monitor.logs.node": "Node",
"cluster.monitor.logs.message": "Message",
"cluster.monitor.logs.search.placeholder": "Search message",
"cluster.monitor.tabs.overview": "Overview",
"cluster.monitor.tabs.advanced": "Advanced",
"cluster.monitor.tabs.topn": "TopN",
"cluster.monitor.tabs.logs": "Logs",
"cluster.monitor.tabs.nodes": "Nodes",
"cluster.monitor.tabs.indices": "Indices",
"cluster.monitor.tabs.shards": "Shards",
"cluster.metrics.axis.index_throughput.title": "Indexing Rate", "cluster.metrics.axis.index_throughput.title": "Indexing Rate",
"cluster.metrics.axis.search_throughput.title": "Search Rate", "cluster.metrics.axis.search_throughput.title": "Search Rate",
"cluster.metrics.axis.index_latency.title": "Indexing Latency", "cluster.metrics.axis.index_latency.title": "Indexing Latency",
@ -371,4 +386,6 @@ export default {
"cluster.collect.last_active_at": "Last Active At", "cluster.collect.last_active_at": "Last Active At",
}; };

View File

@ -115,6 +115,21 @@ export default {
"cluster.monitor.topn.color": "颜色指标", "cluster.monitor.topn.color": "颜色指标",
"cluster.monitor.topn.theme": "主题", "cluster.monitor.topn.theme": "主题",
"cluster.monitor.logs.timestamp": "时间戳",
"cluster.monitor.logs.type": "类型",
"cluster.monitor.logs.level": "等级",
"cluster.monitor.logs.node": "节点",
"cluster.monitor.logs.message": "日志",
"cluster.monitor.logs.search.placeholder": "搜索日志",
"cluster.monitor.tabs.overview": "概览",
"cluster.monitor.tabs.advanced": "高级",
"cluster.monitor.tabs.topn": "TopN",
"cluster.monitor.tabs.logs": "日志",
"cluster.monitor.tabs.nodes": "节点",
"cluster.monitor.tabs.indices": "索引",
"cluster.monitor.tabs.shards": "分片",
"cluster.metrics.axis.index_throughput.title": "索引吞吐", "cluster.metrics.axis.index_throughput.title": "索引吞吐",
"cluster.metrics.axis.search_throughput.title": "查询吞吐", "cluster.metrics.axis.search_throughput.title": "查询吞吐",
"cluster.metrics.axis.index_latency.title": "索引延迟", "cluster.metrics.axis.index_latency.title": "索引延迟",

View File

@ -666,7 +666,7 @@ const Index = (props) => {
onRangeChange={onTimeChange} onRangeChange={onTimeChange}
{...refresh} {...refresh}
onRefreshChange={setRefresh} onRefreshChange={setRefresh}
onRefresh={({ start, end}) => onTimeChange({ start, end, refresh: new Date().valueOf()})} onRefresh={(value) => onTimeChange({ ...(value || {}), refresh: new Date().valueOf()})}
timeZone={timeZone} timeZone={timeZone}
onTimeZoneChange={setTimeZone} onTimeZoneChange={setTimeZone}
recentlyUsedRangesKey={'alerting-message'} recentlyUsedRangesKey={'alerting-message'}

View File

@ -333,7 +333,7 @@ const RuleDetail = (props) => {
onRangeChange={handleTimeChange} onRangeChange={handleTimeChange}
{...refresh} {...refresh}
onRefreshChange={setRefresh} onRefreshChange={setRefresh}
onRefresh={({ start, end}) => handleTimeChange({ start, end, refresh: new Date().valueOf()})} onRefresh={(value) => handleTimeChange({ ...(value || {}), refresh: new Date().valueOf()})}
timeZone={timeZone} timeZone={timeZone}
onTimeZoneChange={setTimeZone} onTimeZoneChange={setTimeZone}
recentlyUsedRangesKey={"rule-detail"} recentlyUsedRangesKey={"rule-detail"}

View File

@ -14,7 +14,7 @@ export default (props) => {
const { record, result, options, isGroup, isLock, onReady, bucketSize, isTimeSeries, highlightRange, currentQueries = {}, handleContextMenu, onChartElementClick } = props; const { record, result, options, isGroup, isLock, onReady, bucketSize, isTimeSeries, highlightRange, currentQueries = {}, handleContextMenu, onChartElementClick } = props;
const { id, is_stack, is_percent, drilling = {}, series, legend } = record; const { id, is_stack, is_percent, drilling = {}, series, legend, colors } = record;
const { metric = {} } = series[0] const { metric = {} } = series[0]
@ -84,6 +84,13 @@ export default (props) => {
} }
} }
if (colors) {
config.color = Array.isArray(colors) ? colors : (value) => {
const { name } = value;
return colors[name];
}
}
const dataDrillingMenuItem = { const dataDrillingMenuItem = {
type: TYPE_DATA_DRILLING, type: TYPE_DATA_DRILLING,
name: formatMessage({id: "dashboard.widget.sub.menu.data.drilling"}), name: formatMessage({id: "dashboard.widget.sub.menu.data.drilling"}),

View File

@ -84,6 +84,7 @@ export const WidgetRender = (props) => {
onResultChange={(res) => { onResultChange={(res) => {
setRequests(Array.isArray(res) ? res.filter((item) => !!item.request).map((item) => item.request) : []) setRequests(Array.isArray(res) ? res.filter((item) => !!item.request).map((item) => item.request) : [])
}} }}
refresh={refresh}
/> />
</div> </div>
) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} /> ) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />

View File

@ -0,0 +1,45 @@
import { Empty, Input, Spin, Table } from "antd";
import { formatMessage } from "umi/locale";
import { Link } from "umi";
import Logs from "../../components/Logs";
const AGGS = {
"Node":{"terms":{"field":"metadata.labels.node_name", "size": 1000 }},
"Type":{"terms":{"field":"metadata.name", "size": 1000 }},
"Level":{"terms":{"field":"payload.level", "size": 1000 }},
}
export default (props) => {
const { clusterID, param = {} } = props;
return (
<Logs
{...props}
aggs={AGGS}
queryFilters={[
{
"term": {
"metadata.labels.cluster_id": clusterID
}
}
]}
extraColumns={[
{
title: formatMessage({ id: "cluster.monitor.logs.node" }),
key: "metadata.labels.node_name",
dataIndex: "metadata.labels.node_name",
width: 88,
ellipsis: true,
render: (value, record) => {
if (!record.metadata?.labels?.node_uuid) return value
return (
<Link to={`/cluster/monitor/${clusterID}/nodes/${record.metadata.labels.node_uuid}?_g=${encodeURIComponent(JSON.stringify(param))}`}>
{value}
</Link>
)
}
},
]}
/>
);
}

View File

@ -9,11 +9,13 @@ import Monitor from "@/components/Overview/Monitor";
import StatisticBar from "./statistic_bar"; import StatisticBar from "./statistic_bar";
import { Empty } from "antd"; import { Empty } from "antd";
import TopN from "./TopN"; import TopN from "./TopN";
import Logs from "./Logs";
const panes = [ const panes = [
{ title: "Overview", component: Overview, key: "overview" }, { title: "Overview", component: Overview, key: "overview" },
{ title: "Advanced", component: Advanced, key: "advanced" }, { title: "Advanced", component: Advanced, key: "advanced" },
{ title: "TopN", component: TopN, key: "topn" }, { title: "TopN", component: TopN, key: "topn" },
{ title: "Logs", component: Logs, key: "logs" },
{ title: "Nodes", component: Nodes, key: "nodes" }, { title: "Nodes", component: Nodes, key: "nodes" },
{ title: "Indices", component: Indices, key: "indices" }, { title: "Indices", component: Indices, key: "indices" },
]; ];

View File

@ -0,0 +1,26 @@
import { Empty, Input, Spin, Table } from "antd";
import { formatMessage } from "umi/locale";
import Logs from "../../components/Logs";
const AGGS = {
"Type":{"terms":{"field":"metadata.name", "size": 1000 }},
"Level":{"terms":{"field":"payload.level", "size": 1000 }},
}
export default (props) => {
const { nodeID } = props;
return (
<Logs
{...props}
aggs={AGGS}
queryFilters={[
{
"term": {
"metadata.labels.node_uuid": nodeID
}
}
]}
/>
);
}

View File

@ -6,10 +6,12 @@ import { formatMessage } from "umi/locale";
import Monitor from "@/components/Overview/Monitor"; import Monitor from "@/components/Overview/Monitor";
import StatisticBar from "./statistic_bar"; import StatisticBar from "./statistic_bar";
import { connect } from "dva"; import { connect } from "dva";
import Logs from "./Logs";
const panes = [ const panes = [
{ title: "Overview", component: Overview, key: "overview" }, { title: "Overview", component: Overview, key: "overview" },
{ title: "Advanced", component: Advanced, key: "advanced" }, { title: "Advanced", component: Advanced, key: "advanced" },
{ title: "Logs", component: Logs, key: "logs" },
{ title: "Shards", component: Shards, key: "shards" }, { title: "Shards", component: Shards, key: "shards" },
]; ];
const Page = (props) => { const Page = (props) => {

View File

@ -0,0 +1,323 @@
import { Empty, Input, Spin, Table } from "antd";
import styles from "./index.less"
import DatePicker from "@/common/src/DatePicker";
import { useEffect, useMemo, useRef, useState } from "react";
import { formatESSearchResult, formatTimeRange } from "@/lib/elasticsearch/util";
import { formatMessage } from "umi/locale";
import request from "@/utils/request";
import moment from "moment";
import { getTimezone } from "@/utils/utils";
import ListView from "@/components/ListView";
import { ESPrefix } from "@/services/common";
import Side from "../Side";
import { WidgetRender } from "@/pages/DataManagement/View/WidgetLoader";
import { cloneDeep } from "lodash";
import { Link } from "umi";
const COLORS = {
'INFO': '#e8eef2',
'WARN': '#e99d43',
'ERROR': '#ff3f3f'
}
export default (props) => {
const { timeRange, isAgent, refresh, aggs, queryFilters = [], extraColumns = [] } = props;
const ref = useRef(null);
const timeField = "timestamp";
const indexName = ".infini_logs";
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(false)
const [queryParams, setQueryParams] = useState({
size: 20,
from: 0,
sort: {
[timeField]: 'desc'
},
keyword: ''
})
const fetchData = async (queryParams, timeRange, aggs, queryFilters) => {
if (!timeRange) return;
setLoading(true)
const newTimeRange = formatTimeRange(timeRange);
const filters = [...queryFilters]
if (queryParams?.filters) {
Object.keys(queryParams.filters).map((field) => {
const values = queryParams.filters[field];
if (Array.isArray(values)) {
values.forEach((item) => {
filters.push({
'term': { [field]: item }
})
});
} else {
if (values) {
filters.push({
'term': { [field]: values }
})
}
}
});
}
if (queryParams?.keyword) {
filters.push({
query_string: {
query: `*${queryParams.keyword}*`,
fields: ['payload.message'],
}
});
}
const res = await request(`${ESPrefix}/infini_default_system_cluster/search/ese?timeout=60m`, {
method: 'POST',
body: {
index: indexName,
body: {
aggs,
query: {
"bool": {
"filter": [
{
"range": {
"timestamp": {
"gte": newTimeRange.min,
"lte": newTimeRange.max
}
}
},
...filters
],
"should": [],
"must_not": []
}
},
size: queryParams.size,
from: queryParams.from,
sort: queryParams.sort ? Object.keys(queryParams.sort).filter((key) => !!queryParams.sort[key]).map((key) => ({
[key]: {
"order": queryParams.sort[key]
}
})) : []
}
}
})
if (res && !res.error) {
const rs = formatESSearchResult(res);
setResult(rs)
}
setLoading(false)
}
const onTableChange = (pagination, filters, sorter, extra) => {
const sortOrder = sorter.order ? sorter.order.replace("end", "") : null;
if (queryParams?.sort[sorter.field] !== sortOrder) {
setQueryParams((st) => ({ ...st, sort: {
...(queryParams.sort || {}),
[sorter.field]: sortOrder
}}));
}
};
useEffect(() => {
fetchData(queryParams, timeRange, aggs, queryFilters)
}, [JSON.stringify(queryParams), timeRange, JSON.stringify(aggs), JSON.stringify(queryFilters)])
const columns = [
{
title: formatMessage({ id: "cluster.monitor.logs.timestamp" }),
key: timeField,
dataIndex: timeField,
width: 170,
render: (value) =>
moment(value)
.tz(getTimezone())
.format("YYYY-MM-DD HH:mm:ss"),
sorter: true,
sortOrder: queryParams.sort?.[timeField] ? `${queryParams.sort?.[timeField]}end` : undefined
},
{
title: formatMessage({ id: "cluster.monitor.logs.type" }),
key: "metadata.name",
dataIndex: "metadata.name",
width: 100,
},
{
title: formatMessage({ id: "cluster.monitor.logs.level" }),
key: "payload.level",
dataIndex: "payload.level",
width: 88,
render: (value, record) => {
if (!value) return '-'
const colors = {
INFO: {
background: COLORS.INFO,
color: "#959ea0",
},
WARN: {
background: COLORS.WARN,
color: "#fff",
},
ERROR: {
background: COLORS.ERROR,
color: "#fff",
},
};
return (
<div
style={{
width: 54,
height: 18,
fontWeight: 600,
lineHeight: "18px",
textAlign: "center",
...(colors[value] || colors[value]),
}}
>
{value}
</div>
);
},
},
...extraColumns,
{
title: formatMessage({ id: "cluster.monitor.logs.message" }),
key: "payload.message",
dataIndex: "payload.message",
},
];
const histogram = {
bucket_size: "auto",
is_stack: true,
format: {
type: "number",
pattern: "0.00a",
},
legend: false,
colors: COLORS,
series: [
{
metric: {
formula: "a",
groups: [
{
field: "payload.level",
limit: 10,
},
],
items: [
{
field: "*",
name: "a",
statistic: "count",
},
],
sort: [
{
direction: "desc",
key: "_count",
},
],
},
queries: {
cluster_id: "infini_default_system_cluster",
indices: [indexName],
time_field: timeField,
},
type: "date-histogram",
},
],
}
const isNotEmpty = useMemo(() => {
return result?.data?.length > 0
}, [result?.data?.length])
return (
<Spin spinning={loading}>
<div className={styles.logs} style={isNotEmpty ? {} : { justifyContent: 'center', alignItems: 'center'}}>
{
isNotEmpty ? (
<>
<div className={styles.side}>
<Side
aggs={aggs}
data={result?.aggregations || {}}
filters={queryParams?.filters || {}}
onFacetChange={(v) => {
const newFilters = cloneDeep(queryParams.filters) || {}
if (!v.value || v.value.length === 0) {
delete newFilters[v.field];
} else {
newFilters[v.field] = v.value;
}
setQueryParams((st) => ({ ...st, from: 0, filters: newFilters }));
}}
onReset={() => {
setQueryParams((st) => ({ ...st, from: 0, filters: {} }));
}}
/>
</div>
<div className={styles.result}>
<div className={styles.header}>
<Input.Search
style={{ maxWidth: 600 }}
placeholder={formatMessage({ id: "cluster.monitor.logs.search.placeholder" })}
onSearch={value => {
setQueryParams((st) => ({ ...st, from: 0, keyword: value }));
}}
enterButton
/>
</div>
<div className={styles.histogram}>
<WidgetRender
widget={histogram}
range={{
from: timeRange.min,
to: timeRange.max,
timeField: timeField,
}}
queryParams={queryParams?.filters || {}}
refresh={refresh}
/>
</div>
<div className={styles.table}>
<Table
size={"small"}
rowKey={"id"}
columns={columns}
dataSource={result?.data || []}
onChange={onTableChange}
pagination={{
size: "small",
pageSize: queryParams.size,
current: Math.ceil(queryParams.from / queryParams.size) + 1,
total: result?.total?.value || result?.total || 0,
onChange: (page, pageSize) => {
setQueryParams((st) => ({
...st,
from: (page - 1) * st.size,
}));
},
showSizeChanger: true,
onShowSizeChange: (_, size) => {
setQueryParams((st) => ({ ...st, from: 0, size }));
},
showTotal: (total, range) =>
`${range[0]}-${range[1]} of ${total} items`,
}}
/>
</div>
</div>
</>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE}/>
)
}
</div>
</Spin>
);
}

View File

@ -0,0 +1,26 @@
.logs {
display: flex;
gap: 12px;
width: 100%;
min-height: calc(100vh - 444px);
.side {
width: 220px;
}
.result {
width: calc(100% - 220px - 12px);
.header {
margin-bottom: 16px;
}
.histogram {
width: 100%;
height: 140px;
border: 1px solid rgb(232, 232, 232);
border-radius: 2px;
margin-bottom: 16px;
}
}
}

View File

@ -0,0 +1,86 @@
import { Checkbox, Icon, Input } from "antd";
import { useEffect, useState } from "react";
import { formatMessage } from "umi/locale";
import styles from "./SearchFacet.less";
export default ({ field, label, data = [], onChange, selectedKeys }) => {
const [filterData, setFilterData] = useState([]);
useEffect(() => {
setFilterData(data);
}, [data]);
const onInputChange = (ev) => {
let lowerStr = ev.target.value.toLowerCase();
setFilterData(
data.filter((item) => {
let lowerKey = item.key.toLowerCase();
return lowerKey.includes(lowerStr);
})
);
};
const onSelectChange = (item, ev) => {
if (ev.target.checked) {
if (selectedKeys.indexOf(item.key) > -1) {
return;
}
const newKeys = [...selectedKeys, item.key];
if (typeof onChange == "function") {
onChange({
field,
value: newKeys,
});
}
} else {
const newKeys = selectedKeys.filter((key) => key != item.key);
if (typeof onChange == "function") {
onChange({
field,
value: newKeys,
});
}
}
};
const [showMore, setShowMore] = useState(false);
return (
<div className={styles.searchFacet}>
<div className={`${styles.line} ${styles.label}`}>{label}</div>
<div className={styles.line}>
<Input
placeholder={`${formatMessage({
id: "listview.side.filter",
})} ${label}`}
onChange={onInputChange}
/>
</div>
<div className={styles.line}>
{(showMore ? filterData : filterData.slice(0, 5)).map((item) => {
return (
<div key={item.key} className={styles.value} title={item.key}>
<Checkbox
onChange={(v) => {
onSelectChange(item, v);
}}
checked={selectedKeys && selectedKeys.indexOf(item.key) > -1}
>
{item?.key_as_string ?? item.key}
</Checkbox>
<div className={styles.count}>{item.doc_count}</div>
</div>
);
})}
{!showMore && filterData.length > 5 ? (
<div>
<a
onClick={() => {
setShowMore(true);
}}
>
<Icon type="plus" />
more
</a>
</div>
) : null}
</div>
</div>
);
};

View File

@ -0,0 +1,30 @@
.searchFacet {
.line {
padding: 4px 0;
&.label {
text-transform: capitalize;
font-size: 12px;
color: #8b9bad;
letter-spacing: 1px;
padding: 0;
}
.value {
display: flex;
align-items: center;
:global {
label.ant-checkbox-wrapper {
width: 152px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.count {
margin-left: auto;
color: #888;
font-size: 0.85em;
text-align: right;
}
}
}
}

View File

@ -0,0 +1,52 @@
import { Icon } from "antd";
import { useMemo, useEffect, useState, useCallback } from "react";
import { formatMessage } from "umi/locale";
import SearchFacet from "./SearchFacet";
import styles from "./index.less";
export default (props) => {
const { aggs, data, filters, onFacetChange, onReset } = props;
const facets = useMemo(() => {
const fts = Object.keys(data).map((item) => {
return {
label: item,
field: aggs[item].terms.field,
buckets: data[item]?.buckets || [],
};
});
return fts;
}, [data]);
return (
<div className={styles.searchFilter}>
<div className={styles.title}>
<span>{formatMessage({ id: "listview.side.filter" })}</span>
<span
className={styles.reload}
onClick={onReset}
title={formatMessage({ id: "listview.side.filter.reset" })}
>
<Icon type="reload" style={{ color: "rgba(0, 127, 255, 1)" }} />
</span>
</div>
<div className={styles.facetCnt}>
{facets.map((ft) => {
if (ft.buckets.length == 0) {
return null;
}
return (
<SearchFacet
key={ft.field}
label={ft.label}
field={ft.field}
data={ft.buckets}
onChange={onFacetChange}
selectedKeys={filters[ft.field] || []}
/>
);
})}
</div>
</div>
);
};

View File

@ -0,0 +1,18 @@
.searchFilter {
height: 100%;
.title {
font-weight: bold;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
.reload {
cursor: pointer;
}
}
.facetCnt {
display: flex;
flex-direction: column;
gap: 1em;
}
}