Skip to content

Commit

Permalink
add new log filter ui
Browse files Browse the repository at this point in the history
  • Loading branch information
jsladerman committed Feb 26, 2025
1 parent 705db6c commit 3c60ebb
Show file tree
Hide file tree
Showing 7 changed files with 379 additions and 105 deletions.
143 changes: 143 additions & 0 deletions assets/src/components/cd/logs/DateTimeFormInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
FillLevelContext,
Flex,
FormField,
SegmentedInput,
SegmentedInputHandle,
SemanticColorKey,
} from '@pluralsh/design-system'
import { Body2P } from 'components/utils/typography/Text'

import { ComponentPropsWithRef, useEffect, useRef, useState } from 'react'
import styled, { useTheme } from 'styled-components'
import { DateParam, formatDateTime, isValidDateTime } from 'utils/datetime'

const EMPTY_DATE_STR = '//'
const EMPTY_TIME_STR = '::'

export function DateTimeFormInput({
initialDate,
setDate,
...props
}: {
initialDate?: DateParam
setDate?: (date?: DateParam) => void
} & Omit<ComponentPropsWithRef<typeof FormField>, 'caption'>) {
const { colors } = useTheme()
const initDateStr = formatDateTime(initialDate, 'M/D/YYYY') || EMPTY_DATE_STR
const initTimeStr = formatDateTime(initialDate, 'H:m:s') || EMPTY_TIME_STR
const [initDateM, initDateD, initDateY] = initDateStr.split('/')
const [initTimeH, initTimeM, initTimeS] = initTimeStr.split(':')

const [dateStr, setDateStr] = useState('')
const [timeStr, setTimeStr] = useState('')
const isSetToNow = dateStr === EMPTY_DATE_STR && timeStr === EMPTY_TIME_STR

const dateValid = isValidDateTime(dateStr, 'M/D/YYYY', true)
const dateChanged = dateStr !== initDateStr
const dateError = !isSetToNow && !dateValid && dateChanged
const timeValid = isValidDateTime(timeStr, 'H:m:s')
const timeChanged = timeStr !== initTimeStr
const timeError = !isSetToNow && !timeValid && timeChanged

const dateInputRef = useRef<SegmentedInputHandle>(null)
const timeInputRef = useRef<SegmentedInputHandle>(null)

// does an imperative clear, and manually sets state accordingly
// necessary because SegmentedInput component is not fully controlled
const clearDateTime = () => {
dateInputRef.current?.clear()
timeInputRef.current?.clear()
setDateStr(EMPTY_DATE_STR)
setTimeStr(EMPTY_TIME_STR)
setDate?.(undefined)
}

// keep parent in sync
useEffect(() => {
if (isSetToNow) return
if (dateValid && timeValid && (dateChanged || timeChanged))
setDate?.(`${dateStr} ${timeStr}`)
else setDate?.(initialDate)
}, [

Check warning on line 62 in assets/src/components/cd/logs/DateTimeFormInput.tsx

View workflow job for this annotation

GitHub Actions / Lint

React Hook useEffect has a missing dependency: 'isSetToNow'. Either include it or remove the dependency array
dateStr,
setDate,
timeStr,
initialDate,
dateValid,
dateChanged,
timeValid,
timeChanged,
])

return (
<FillLevelContext value={0}>
<FormField
hint="All logs displayed will be before this date/time"
caption={
<CaptionWrapperSC>
<CaptionTextBtnSC
$disabled={isSetToNow}
onClick={isSetToNow ? undefined : clearDateTime}
>
Set to now
</CaptionTextBtnSC>
<CaptionTextBtnSC $color="text-primary-accent">
Enter timestamp
</CaptionTextBtnSC>
</CaptionWrapperSC>
}
{...props}
>
<Flex
gap="xsmall"
direction="column"
>
<SegmentedInput
ref={dateInputRef}
{...(isSetToNow && { value: 'Today' })}
style={{ borderColor: dateError && colors['border-danger'] }}
prefix="Date"
endIcon={<Body2P $color="text-xlight">MM/DD/YYYY</Body2P>}
onChange={setDateStr}
separator="/"
segments={[
{ length: 2, max: 12, name: 'MM', initialVal: initDateM },
{ length: 2, min: 1, max: 31, name: 'DD', initialVal: initDateD },
{ length: 4, max: 9999, name: 'YYYY', initialVal: initDateY },
]}
/>
<SegmentedInput
ref={timeInputRef}
{...(isSetToNow && { value: 'Now' })}
style={{ borderColor: timeError && colors['border-danger'] }}
prefix="Time"
endIcon={<Body2P $color="text-xlight">UTC</Body2P>}
onChange={setTimeStr}
separator=":"
segments={[
{ length: 2, max: 23, name: 'HH', initialVal: initTimeH },
{ length: 2, max: 59, name: 'MM', initialVal: initTimeM },
{ length: 2, max: 59, name: 'SS', initialVal: initTimeS },
]}
/>
</Flex>
</FormField>
</FillLevelContext>
)
}

const CaptionWrapperSC = styled.span(({ theme }) => ({
display: 'flex',
gap: theme.spacing.medium,
}))

const CaptionTextBtnSC = styled.span<{
$color?: SemanticColorKey
$disabled?: boolean
}>(({ theme, $color = 'text-xlight', $disabled = false }) => ({
color: theme.colors[$color],
cursor: $disabled ? 'default' : 'pointer',
opacity: $disabled ? 0.4 : 1,
'&:hover': { textDecoration: $disabled ? 'none' : 'underline' },
}))
80 changes: 11 additions & 69 deletions assets/src/components/cd/logs/Logs.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,12 @@
import {
Input,
ListBoxItem,
SearchIcon,
Select,
Toast,
} from '@pluralsh/design-system'
import { Flex, Input, SearchIcon, Toast } from '@pluralsh/design-system'
import { LogsLabels } from 'components/cd/logs/LogsLabels'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useState } from 'react'

import { useThrottle } from 'components/hooks/useThrottle'
import { Body2P } from 'components/utils/typography/Text'
import { LogFacetInput } from 'generated/graphql'
import { clamp } from 'lodash'
import styled, { useTheme } from 'styled-components'
import {
SinceSecondsOptions,
SinceSecondsSelectOptions,
} from '../cluster/pod/logs/Logs'
import { LogsCard } from './LogsCard'

const MAX_QUERY_LENGTH = 250
import { DEFAULT_LOG_FILTERS, LogsFilters, LogsFiltersT } from './LogsFilters'

export function Logs({
serviceId,
Expand All @@ -33,22 +20,11 @@ export function Logs({
const theme = useTheme()
const [showErrorToast, setShowErrorToast] = useState(false)
const [showSuccessToast, setShowSuccessToast] = useState(false)
const [sinceSeconds, setSinceSeconds] = useState(
SinceSecondsOptions.QuarterHour
)

const [labels, setLabels] = useState<LogFacetInput[]>([])
const [q, setQ] = useState('')
const [queryLimit, setQueryLimit] = useState(100)
const throttledQ = useThrottle(q, 1000)
const throttledQueryLimit = useThrottle(queryLimit, 300)

const time = useMemo(
() => ({
duration: secondsToDuration(sinceSeconds),
reverse: false,
}),
[sinceSeconds]
)
const [filters, setFilters] = useState<LogsFiltersT>(DEFAULT_LOG_FILTERS)

const addLabel = useCallback(
(key: string, value: string) => {
Expand All @@ -71,47 +47,19 @@ export function Logs({
return (
<PageWrapperSC>
<MainContentWrapperSC>
<FiltersWrapperSC>
<Flex gap="small">
<Input
placeholder="Filter logs"
startIcon={<SearchIcon size={14} />}
value={q}
onChange={({ target: { value } }) => setQ(value)}
flex={1}
/>
<Input
endIcon={<Body2P $color="text-xlight">lines</Body2P>}
inputProps={{
css: {
textAlign: 'right',
'&::-webkit-outer-spin-button, &::-webkit-inner-spin-button': {
WebkitAppearance: 'none',
},
},
}}
width={220}
prefix="Query length"
type="number"
value={queryLimit || ''}
onChange={({ target: { value } }) =>
setQueryLimit(clamp(Number(value), 10, MAX_QUERY_LENGTH))
}
<LogsFilters
curFilters={filters}
setCurFilters={setFilters}
/>
<Select
selectedKey={`${sinceSeconds}`}
onSelectionChange={(key) =>
setSinceSeconds(key as SinceSecondsOptions)
}
>
{SinceSecondsSelectOptions.map((opts) => (
<ListBoxItem
key={`${opts.key}`}
label={opts.label}
selected={opts.key === sinceSeconds}
/>
))}
</Select>
</FiltersWrapperSC>
</Flex>
<LogsLabels
labels={labels}
removeLabel={removeLabel}
Expand All @@ -120,8 +68,7 @@ export function Logs({
serviceId={serviceId}
clusterId={clusterId}
query={throttledQ}
limit={throttledQueryLimit}
time={time}
filters={filters}
labels={labels}
addLabel={addLabel}
showLegendTooltip={showLegendTooltip}
Expand Down Expand Up @@ -163,11 +110,6 @@ const PageWrapperSC = styled.div(({ theme }) => ({
width: '100%',
}))

const FiltersWrapperSC = styled.div(({ theme }) => ({
display: 'flex',
gap: theme.spacing.small,
}))

const MainContentWrapperSC = styled.div(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
Expand Down
38 changes: 21 additions & 17 deletions assets/src/components/cd/logs/LogsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ import {
IconFrame,
InfoOutlineIcon,
} from '@pluralsh/design-system'
import LogsScrollIndicator from 'components/cd/logs/LogsScrollIndicator'
import { LogsScrollIndicator } from 'components/cd/logs/LogsScrollIndicator'
import { GqlError } from 'components/utils/Alert'
import {
LogFacetInput,
LogLineFragment,
LogTimeRange,
useLogAggregationQuery,
} from 'generated/graphql'
import { isEmpty } from 'lodash'
import { memo, useCallback, useMemo, useState } from 'react'
import { toISOStringOrUndef } from 'utils/datetime'
import { isNonNullable } from 'utils/isNonNullable'
import { LogContextPanel } from './LogContextPanel'
import { secondsToDuration } from './Logs'
import { type LogsFiltersT } from './LogsFilters'
import LogsLegend from './LogsLegend'
import { LogsTable } from './LogsTable'

Expand All @@ -27,34 +29,39 @@ export const LogsCard = memo(function LogsCard({
serviceId,
clusterId,
query,
limit,
time,
filters,
labels,
addLabel,
showLegendTooltip = true,
}: {
serviceId?: string
clusterId?: string
query?: string
limit?: number
time?: LogTimeRange
filters: LogsFiltersT
labels?: LogFacetInput[]
addLabel?: (key: string, value: string) => void
showLegendTooltip?: boolean
}) {
const { queryLength, sinceSeconds, date } = filters
const [contextPanelOpen, setContextPanelOpen] = useState(false)
const [logLine, setLogLine] = useState<Nullable<LogLineFragment>>(null)
const [live, setLive] = useState(true)
const [hasNextPage, setHasNextPage] = useState(true)

const filterDateStr = toISOStringOrUndef(date)
const duration = secondsToDuration(sinceSeconds)
const { data, loading, error, fetchMore, startPolling, stopPolling } =
useLogAggregationQuery({
variables: {
clusterId,
query,
limit: limit || LIMIT,
limit: filters.queryLength || LIMIT,
serviceId,
time,
time: {
before: live ? undefined : filterDateStr,
duration,
reverse: false,
},
facets: labels,
},
fetchPolicy: 'cache-and-network',
Expand All @@ -63,10 +70,7 @@ export const LogsCard = memo(function LogsCard({
})

const logs = useMemo(
() =>
data?.logAggregation?.filter(
(log): log is LogLineFragment => log !== null
) ?? [],
() => data?.logAggregation?.filter(isNonNullable) ?? [],
[data]
)

Expand All @@ -80,10 +84,10 @@ export const LogsCard = memo(function LogsCard({
if (loading || !hasNextPage) return
fetchMore({
variables: {
limit: limit || LIMIT,
limit: queryLength || LIMIT,
time: {
before: logs[logs.length - 1]?.timestamp,
duration: time?.duration,
duration,
},
},
updateQuery: (prev, { fetchMoreResult }) => {
Expand All @@ -96,7 +100,7 @@ export const LogsCard = memo(function LogsCard({
return { logAggregation: [...(prev.logAggregation ?? []), ...newLogs] }
},
})
}, [loading, hasNextPage, fetchMore, limit, logs, time?.duration])
}, [loading, hasNextPage, fetchMore, queryLength, logs, duration])

const initialLoading = !data && loading

Expand Down Expand Up @@ -142,7 +146,7 @@ export const LogsCard = memo(function LogsCard({
loading={loading}
initialLoading={initialLoading}
fetchMore={fetchOlderLogs}
hasNextPage={hasNextPage && logs.length >= (limit || LIMIT)}
hasNextPage={hasNextPage && logs.length >= (queryLength || LIMIT)}
onRowClick={(_, row) => {
setLogLine(row.original)
setContextPanelOpen(true)
Expand All @@ -159,7 +163,7 @@ export const LogsCard = memo(function LogsCard({
<LogContextPanel
logLine={logLine}
addLabel={addLabel}
curDuration={time?.duration ?? secondsToDuration(900)}
curDuration={duration}
queryVars={{
clusterId,
serviceId,
Expand Down
Loading

0 comments on commit 3c60ebb

Please sign in to comment.