Merge pull request #16605 from prometheus/rules-page-filters
	
		
			
	
		
	
	
		
			
				
	
				buf.build / lint and publish (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Go tests (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / More Go tests (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Go tests with previous Go version (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / UI tests (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Go tests on Windows (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Mixins tests (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for common architectures (0) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for common architectures (1) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for common architectures (2) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (0) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (1) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (10) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (11) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (2) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (3) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (4) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (5) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (6) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (7) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (8) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (9) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Report status of build Prometheus for all architectures (push) Blocked by required conditions
				
					Details
				
			
		
			
				
	
				CI / Check generated parser (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / golangci-lint (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / fuzzing (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / codeql (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Publish main branch artifacts (push) Blocked by required conditions
				
					Details
				
			
		
			
				
	
				CI / Publish release artefacts (push) Blocked by required conditions
				
					Details
				
			
		
			
				
	
				CI / Publish UI on npm Registry (push) Blocked by required conditions
				
					Details
				
			
		
			
				
	
				Scorecards supply-chain security / Scorecards analysis (push) Waiting to run
				
					Details
				
			
		
	
				
					
				
			
				
	
				buf.build / lint and publish (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Go tests (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / More Go tests (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Go tests with previous Go version (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / UI tests (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Go tests on Windows (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Mixins tests (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for common architectures (0) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for common architectures (1) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for common architectures (2) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (0) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (1) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (10) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (11) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (2) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (3) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (4) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (5) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (6) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (7) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (8) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Build Prometheus for all architectures (9) (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Report status of build Prometheus for all architectures (push) Blocked by required conditions
				
					Details
				
			
		
			
				
	
				CI / Check generated parser (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / golangci-lint (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / fuzzing (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / codeql (push) Waiting to run
				
					Details
				
			
		
			
				
	
				CI / Publish main branch artifacts (push) Blocked by required conditions
				
					Details
				
			
		
			
				
	
				CI / Publish release artefacts (push) Blocked by required conditions
				
					Details
				
			
		
			
				
	
				CI / Publish UI on npm Registry (push) Blocked by required conditions
				
					Details
				
			
		
			
				
	
				Scorecards supply-chain security / Scorecards analysis (push) Waiting to run
				
					Details
				
			
		
	Add health & text filtering on the /rules page
This commit is contained in:
		
						commit
						6c930e8506
					
				|  | @ -37,7 +37,7 @@ type RecordingRule = { | ||||||
| 
 | 
 | ||||||
| export type Rule = AlertingRule | RecordingRule; | export type Rule = AlertingRule | RecordingRule; | ||||||
| 
 | 
 | ||||||
| interface RuleGroup { | export interface RuleGroup { | ||||||
|   name: string; |   name: string; | ||||||
|   file: string; |   file: string; | ||||||
|   interval: string; |   interval: string; | ||||||
|  |  | ||||||
|  | @ -217,12 +217,7 @@ export default function AlertsPage() { | ||||||
|   const renderedPageItems = useMemo( |   const renderedPageItems = useMemo( | ||||||
|     () => |     () => | ||||||
|       currentPageGroups.map((g) => ( |       currentPageGroups.map((g) => ( | ||||||
|         <Card |         <Card shadow="xs" withBorder p="md" key={`${g.file}-${g.name}`}> | ||||||
|           shadow="xs" |  | ||||||
|           withBorder |  | ||||||
|           p="md" |  | ||||||
|           key={`${g.file}-${g.name}`} |  | ||||||
|         > |  | ||||||
|           <Group mb="sm" justify="space-between"> |           <Group mb="sm" justify="space-between"> | ||||||
|             <Group align="baseline"> |             <Group align="baseline"> | ||||||
|               <Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)"> |               <Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)"> | ||||||
|  | @ -460,7 +455,6 @@ export default function AlertsPage() { | ||||||
|           </Alert> |           </Alert> | ||||||
|         ) |         ) | ||||||
|       )} |       )} | ||||||
|       <Stack> |  | ||||||
|       <Pagination |       <Pagination | ||||||
|         total={totalPageCount} |         total={totalPageCount} | ||||||
|         value={effectiveActivePage} |         value={effectiveActivePage} | ||||||
|  | @ -469,6 +463,5 @@ export default function AlertsPage() { | ||||||
|       /> |       /> | ||||||
|       {renderedPageItems} |       {renderedPageItems} | ||||||
|     </Stack> |     </Stack> | ||||||
|     </Stack> |  | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| import { | import { | ||||||
|   Accordion, |   Accordion, | ||||||
|   Alert, |   Alert, | ||||||
|  |   Anchor, | ||||||
|   Badge, |   Badge, | ||||||
|   Card, |   Card, | ||||||
|   Group, |   Group, | ||||||
|  | @ -8,9 +9,9 @@ import { | ||||||
|   rem, |   rem, | ||||||
|   Stack, |   Stack, | ||||||
|   Text, |   Text, | ||||||
|  |   TextInput, | ||||||
|   Tooltip, |   Tooltip, | ||||||
| } from "@mantine/core"; | } from "@mantine/core"; | ||||||
| // import { useQuery } from "react-query";
 |  | ||||||
| import { | import { | ||||||
|   humanizeDurationRelative, |   humanizeDurationRelative, | ||||||
|   humanizeDuration, |   humanizeDuration, | ||||||
|  | @ -23,17 +24,55 @@ import { | ||||||
|   IconInfoCircle, |   IconInfoCircle, | ||||||
|   IconRefresh, |   IconRefresh, | ||||||
|   IconRepeat, |   IconRepeat, | ||||||
|  |   IconSearch, | ||||||
|   IconTimeline, |   IconTimeline, | ||||||
| } from "@tabler/icons-react"; | } from "@tabler/icons-react"; | ||||||
| import { useSuspenseAPIQuery } from "../api/api"; | import { useSuspenseAPIQuery } from "../api/api"; | ||||||
| import { RulesResult } from "../api/responseTypes/rules"; | import { Rule, RuleGroup, RulesResult } from "../api/responseTypes/rules"; | ||||||
| import badgeClasses from "../Badge.module.css"; | import badgeClasses from "../Badge.module.css"; | ||||||
| import RuleDefinition from "../components/RuleDefinition"; | import RuleDefinition from "../components/RuleDefinition"; | ||||||
| import { badgeIconStyle } from "../styles"; | import { badgeIconStyle, inputIconStyle } from "../styles"; | ||||||
| import { NumberParam, useQueryParam, withDefault } from "use-query-params"; | import { | ||||||
|  |   ArrayParam, | ||||||
|  |   NumberParam, | ||||||
|  |   StringParam, | ||||||
|  |   useQueryParam, | ||||||
|  |   withDefault, | ||||||
|  | } from "use-query-params"; | ||||||
| import { useSettings } from "../state/settingsSlice"; | import { useSettings } from "../state/settingsSlice"; | ||||||
| import { useEffect } from "react"; | import { useEffect, useMemo } from "react"; | ||||||
| import CustomInfiniteScroll from "../components/CustomInfiniteScroll"; | import CustomInfiniteScroll from "../components/CustomInfiniteScroll"; | ||||||
|  | import { useDebouncedValue, useLocalStorage } from "@mantine/hooks"; | ||||||
|  | import { KVSearch } from "@nexucis/kvsearch"; | ||||||
|  | import { StateMultiSelect } from "../components/StateMultiSelect"; | ||||||
|  | 
 | ||||||
|  | const kvSearch = new KVSearch<Rule>({ | ||||||
|  |   shouldSort: true, | ||||||
|  |   indexedKeys: ["name", "labels", ["labels", /.*/]], | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | type RulesPageData = { | ||||||
|  |   groups: (RuleGroup & { prefilterRulesCount: number })[]; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const buildRulesPageData = ( | ||||||
|  |   data: RulesResult, | ||||||
|  |   search: string, | ||||||
|  |   healthFilter: (string | null)[] | ||||||
|  | ): RulesPageData => { | ||||||
|  |   const groups = data.groups.map((group) => ({ | ||||||
|  |     ...group, | ||||||
|  |     prefilterRulesCount: group.rules.length, | ||||||
|  |     rules: (search === "" | ||||||
|  |       ? group.rules | ||||||
|  |       : kvSearch.filter(search, group.rules).map((value) => value.original) | ||||||
|  |     ).filter( | ||||||
|  |       (r) => healthFilter.length === 0 || healthFilter.includes(r.health) | ||||||
|  |     ), | ||||||
|  |   })); | ||||||
|  | 
 | ||||||
|  |   return { groups }; | ||||||
|  | }; | ||||||
| 
 | 
 | ||||||
| const healthBadgeClass = (state: string) => { | const healthBadgeClass = (state: string) => { | ||||||
|   switch (state) { |   switch (state) { | ||||||
|  | @ -48,18 +87,53 @@ const healthBadgeClass = (state: string) => { | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | // Should be defined as a constant here instead of inline as a value
 | ||||||
|  | // to avoid unnecessary re-renders. Otherwise the empty array has
 | ||||||
|  | // a different reference on each render and causes subsequent memoized
 | ||||||
|  | // computations to re-run as long as no health filter is selected.
 | ||||||
|  | const emptyHealthFilter: string[] = []; | ||||||
|  | 
 | ||||||
| export default function RulesPage() { | export default function RulesPage() { | ||||||
|   const { data } = useSuspenseAPIQuery<RulesResult>({ path: `/rules` }); |   const { data } = useSuspenseAPIQuery<RulesResult>({ path: `/rules` }); | ||||||
|   const { ruleGroupsPerPage } = useSettings(); |  | ||||||
| 
 | 
 | ||||||
|  |   // Define URL query params.
 | ||||||
|  |   const [healthFilter, setHealthFilter] = useQueryParam( | ||||||
|  |     "health", | ||||||
|  |     withDefault(ArrayParam, emptyHealthFilter) | ||||||
|  |   ); | ||||||
|  |   const [searchFilter, setSearchFilter] = useQueryParam( | ||||||
|  |     "search", | ||||||
|  |     withDefault(StringParam, "") | ||||||
|  |   ); | ||||||
|  |   const [debouncedSearch] = useDebouncedValue<string>(searchFilter.trim(), 250); | ||||||
|  |   const [showEmptyGroups, setShowEmptyGroups] = useLocalStorage<boolean>({ | ||||||
|  |     key: "alertsPage.showEmptyGroups", | ||||||
|  |     defaultValue: false, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   const { ruleGroupsPerPage } = useSettings(); | ||||||
|   const [activePage, setActivePage] = useQueryParam( |   const [activePage, setActivePage] = useQueryParam( | ||||||
|     "page", |     "page", | ||||||
|     withDefault(NumberParam, 1) |     withDefault(NumberParam, 1) | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   // If we were e.g. on page 10 and the number of total pages decreases to 5 (due
 |   // Update the page data whenever the fetched data or filters change.
 | ||||||
|   // changing the max number of items per page), go to the largest possible page.
 |   const rulesPageData = useMemo( | ||||||
|   const totalPageCount = Math.ceil(data.data.groups.length / ruleGroupsPerPage); |     () => buildRulesPageData(data.data, debouncedSearch, healthFilter), | ||||||
|  |     [data, healthFilter, debouncedSearch] | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   const shownGroups = useMemo( | ||||||
|  |     () => | ||||||
|  |       showEmptyGroups | ||||||
|  |         ? rulesPageData.groups | ||||||
|  |         : rulesPageData.groups.filter((g) => g.rules.length > 0), | ||||||
|  |     [rulesPageData.groups, showEmptyGroups] | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   // If we were e.g. on page 10 and the number of total pages decreases to 5 (due to filtering
 | ||||||
|  |   // or changing the max number of items per page), go to the largest possible page.
 | ||||||
|  |   const totalPageCount = Math.ceil(shownGroups.length / ruleGroupsPerPage); | ||||||
|   const effectiveActivePage = Math.max(1, Math.min(activePage, totalPageCount)); |   const effectiveActivePage = Math.max(1, Math.min(activePage, totalPageCount)); | ||||||
| 
 | 
 | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|  | @ -68,32 +142,25 @@ export default function RulesPage() { | ||||||
|     } |     } | ||||||
|   }, [effectiveActivePage, activePage, setActivePage]); |   }, [effectiveActivePage, activePage, setActivePage]); | ||||||
| 
 | 
 | ||||||
|   return ( |   const currentPageGroups = useMemo( | ||||||
|     <Stack mt="xs"> |     () => | ||||||
|       {data.data.groups.length === 0 && ( |       shownGroups.slice( | ||||||
|         <Alert title="No rule groups" icon={<IconInfoCircle />}> |  | ||||||
|           No rule groups configured. |  | ||||||
|         </Alert> |  | ||||||
|       )} |  | ||||||
|       <Pagination |  | ||||||
|         total={totalPageCount} |  | ||||||
|         value={effectiveActivePage} |  | ||||||
|         onChange={setActivePage} |  | ||||||
|         hideWithOnePage |  | ||||||
|       /> |  | ||||||
|       {data.data.groups |  | ||||||
|         .slice( |  | ||||||
|         (effectiveActivePage - 1) * ruleGroupsPerPage, |         (effectiveActivePage - 1) * ruleGroupsPerPage, | ||||||
|         effectiveActivePage * ruleGroupsPerPage |         effectiveActivePage * ruleGroupsPerPage | ||||||
|         ) |       ), | ||||||
|         .map((g) => ( |     [shownGroups, effectiveActivePage, ruleGroupsPerPage] | ||||||
|           <Card |   ); | ||||||
|             shadow="xs" | 
 | ||||||
|             withBorder |   // We memoize the actual rendering of the page items to avoid re-rendering
 | ||||||
|             p="md" |   // them on every state change. This is especially important when the user
 | ||||||
|             mb="md" |   // types into the search box, as the search filter changes on every keystroke,
 | ||||||
|             key={`${g.file}-${g.name}`} |   // even before debouncing takes place (extracting the filters and results list
 | ||||||
|           > |   // into separate components would be an alternative to this, but it's kinda
 | ||||||
|  |   // convenient to have in the same file IMO).
 | ||||||
|  |   const renderedPageItems = useMemo( | ||||||
|  |     () => | ||||||
|  |       currentPageGroups.map((g) => ( | ||||||
|  |         <Card shadow="xs" withBorder p="md" key={`${g.file}-${g.name}`}> | ||||||
|           <Group mb="sm" justify="space-between"> |           <Group mb="sm" justify="space-between"> | ||||||
|             <Group align="baseline"> |             <Group align="baseline"> | ||||||
|               <Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)"> |               <Text fz="xl" fw={600} c="var(--mantine-primary-color-filled)"> | ||||||
|  | @ -136,11 +203,30 @@ export default function RulesPage() { | ||||||
|               </Tooltip> |               </Tooltip> | ||||||
|             </Group> |             </Group> | ||||||
|           </Group> |           </Group> | ||||||
|             {g.rules.length === 0 && ( |           {g.prefilterRulesCount === 0 ? ( | ||||||
|             <Alert title="No rules" icon={<IconInfoCircle />}> |             <Alert title="No rules" icon={<IconInfoCircle />}> | ||||||
|                 No rules in rule group. |               No rules in this group. | ||||||
|  |               <Anchor | ||||||
|  |                 ml="md" | ||||||
|  |                 fz="1em" | ||||||
|  |                 onClick={() => setShowEmptyGroups(false)} | ||||||
|  |               > | ||||||
|  |                 Hide empty groups | ||||||
|  |               </Anchor> | ||||||
|             </Alert> |             </Alert> | ||||||
|             )} |           ) : g.rules.length === 0 ? ( | ||||||
|  |             <Alert title="No matching rules" icon={<IconInfoCircle />}> | ||||||
|  |               No rules in this group match your filter criteria (omitted{" "} | ||||||
|  |               {g.prefilterRulesCount} filtered rules). | ||||||
|  |               <Anchor | ||||||
|  |                 ml="md" | ||||||
|  |                 fz="1em" | ||||||
|  |                 onClick={() => setShowEmptyGroups(false)} | ||||||
|  |               > | ||||||
|  |                 Hide empty groups | ||||||
|  |               </Anchor> | ||||||
|  |             </Alert> | ||||||
|  |           ) : ( | ||||||
|             <CustomInfiniteScroll |             <CustomInfiniteScroll | ||||||
|               allItems={g.rules} |               allItems={g.rules} | ||||||
|               child={({ items }) => ( |               child={({ items }) => ( | ||||||
|  | @ -248,8 +334,64 @@ export default function RulesPage() { | ||||||
|                 </Accordion> |                 </Accordion> | ||||||
|               )} |               )} | ||||||
|             /> |             /> | ||||||
|  |           )} | ||||||
|         </Card> |         </Card> | ||||||
|         ))} |       )), | ||||||
|  |     [currentPageGroups, setShowEmptyGroups] | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <Stack mt="xs"> | ||||||
|  |       <Group> | ||||||
|  |         <StateMultiSelect | ||||||
|  |           options={["ok", "unknown", "err"]} | ||||||
|  |           optionClass={(o) => | ||||||
|  |             o === "ok" | ||||||
|  |               ? badgeClasses.healthOk | ||||||
|  |               : o === "unknown" | ||||||
|  |                 ? badgeClasses.healthWarn | ||||||
|  |                 : badgeClasses.healthErr | ||||||
|  |           } | ||||||
|  |           placeholder="Filter by rule health" | ||||||
|  |           values={(healthFilter?.filter((v) => v !== null) as string[]) || []} | ||||||
|  |           onChange={(values) => setHealthFilter(values)} | ||||||
|  |         /> | ||||||
|  |         <TextInput | ||||||
|  |           flex={1} | ||||||
|  |           leftSection={<IconSearch style={inputIconStyle} />} | ||||||
|  |           placeholder="Filter by rule name or labels" | ||||||
|  |           value={searchFilter || ""} | ||||||
|  |           onChange={(event) => | ||||||
|  |             setSearchFilter(event.currentTarget.value || null) | ||||||
|  |           } | ||||||
|  |         ></TextInput> | ||||||
|  |       </Group> | ||||||
|  |       {rulesPageData.groups.length === 0 ? ( | ||||||
|  |         <Alert title="No rules found" icon={<IconInfoCircle />}> | ||||||
|  |           No rules found. | ||||||
|  |         </Alert> | ||||||
|  |       ) : ( | ||||||
|  |         !showEmptyGroups && | ||||||
|  |         rulesPageData.groups.length !== shownGroups.length && ( | ||||||
|  |           <Alert | ||||||
|  |             title="Hiding groups with no matching rules" | ||||||
|  |             icon={<IconInfoCircle />} | ||||||
|  |           > | ||||||
|  |             Hiding {rulesPageData.groups.length - shownGroups.length} empty | ||||||
|  |             groups due to filters or no rules. | ||||||
|  |             <Anchor ml="md" fz="1em" onClick={() => setShowEmptyGroups(true)}> | ||||||
|  |               Show empty groups | ||||||
|  |             </Anchor> | ||||||
|  |           </Alert> | ||||||
|  |         ) | ||||||
|  |       )} | ||||||
|  |       <Pagination | ||||||
|  |         total={totalPageCount} | ||||||
|  |         value={effectiveActivePage} | ||||||
|  |         onChange={setActivePage} | ||||||
|  |         hideWithOnePage | ||||||
|  |       /> | ||||||
|  |       {renderedPageItems} | ||||||
|     </Stack> |     </Stack> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue