diff --git a/assets/src/components/cd/logs/DateTimeFormInput.tsx b/assets/src/components/cd/logs/DateTimeFormInput.tsx new file mode 100644 index 000000000..723077e8d --- /dev/null +++ b/assets/src/components/cd/logs/DateTimeFormInput.tsx @@ -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, '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(null) + const timeInputRef = useRef(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) + }, [ + dateStr, + setDate, + timeStr, + initialDate, + dateValid, + dateChanged, + timeValid, + timeChanged, + ]) + + return ( + + + + Set to now + + + Enter timestamp + + + } + {...props} + > + + MM/DD/YYYY} + 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 }, + ]} + /> + UTC} + 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 }, + ]} + /> + + + + ) +} + +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' }, +})) diff --git a/assets/src/components/cd/logs/Logs.tsx b/assets/src/components/cd/logs/Logs.tsx index 5ea115c19..1e17e22b2 100644 --- a/assets/src/components/cd/logs/Logs.tsx +++ b/assets/src/components/cd/logs/Logs.tsx @@ -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, @@ -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([]) 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(DEFAULT_LOG_FILTERS) const addLabel = useCallback( (key: string, value: string) => { @@ -71,7 +47,7 @@ export function Logs({ return ( - + } @@ -79,39 +55,11 @@ export function Logs({ onChange={({ target: { value } }) => setQ(value)} flex={1} /> - lines} - 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)) - } + - - + ({ width: '100%', })) -const FiltersWrapperSC = styled.div(({ theme }) => ({ - display: 'flex', - gap: theme.spacing.small, -})) - const MainContentWrapperSC = styled.div(({ theme }) => ({ display: 'flex', flexDirection: 'column', diff --git a/assets/src/components/cd/logs/LogsCard.tsx b/assets/src/components/cd/logs/LogsCard.tsx index e300083ed..92847f9e7 100644 --- a/assets/src/components/cd/logs/LogsCard.tsx +++ b/assets/src/components/cd/logs/LogsCard.tsx @@ -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' @@ -27,8 +29,7 @@ export const LogsCard = memo(function LogsCard({ serviceId, clusterId, query, - limit, - time, + filters, labels, addLabel, showLegendTooltip = true, @@ -36,25 +37,31 @@ export const LogsCard = memo(function LogsCard({ 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>(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', @@ -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] ) @@ -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 }) => { @@ -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 @@ -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) @@ -159,7 +163,7 @@ export const LogsCard = memo(function LogsCard({ void +}) { + const [open, setOpen] = useState(false) + + return ( + <> + + setOpen(false)} + header="Log filters" + > + + + + ) +} + +function LogsFiltersForm({ + initialForm, + onSubmit, +}: { + initialForm: LogsFiltersT + onSubmit: (form: LogsFiltersT) => void +}) { + const { + state, + update: updateState, + initialState, + hasUpdates, + } = useUpdateState(initialForm) + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + onSubmit(state) + } + console.log(state) + + return ( + + updateState({ date })} + /> + + + + + lines} + inputProps={{ + css: { + textAlign: 'right', + '&::-webkit-outer-spin-button, &::-webkit-inner-spin-button': { + WebkitAppearance: 'none', + }, + }, + }} + width={220} + prefix="Query length" + type="number" + value={state.queryLength || ''} + onChange={({ target: { value } }) => + updateState({ + queryLength: clamp(Number(value), 10, MAX_QUERY_LENGTH), + }) + } + /> + + + + + + + ) +} + +const WrapperFormSC = styled.form(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing.xlarge, +})) diff --git a/assets/src/components/cd/logs/LogsScrollIndicator.tsx b/assets/src/components/cd/logs/LogsScrollIndicator.tsx index e3f41376d..c9d0b7c78 100644 --- a/assets/src/components/cd/logs/LogsScrollIndicator.tsx +++ b/assets/src/components/cd/logs/LogsScrollIndicator.tsx @@ -1,4 +1,4 @@ -import { Button } from '@pluralsh/design-system' +import { Button, Tooltip, WrapWithIf } from '@pluralsh/design-system' import styled, { useTheme } from 'styled-components' export const LiveIcon = styled.div<{ $live: boolean }>(({ theme, $live }) => ({ @@ -11,7 +11,7 @@ export const LiveIcon = styled.div<{ $live: boolean }>(({ theme, $live }) => ({ transition: 'background-color 0.2s ease-in-out', })) -export default function LogsScrollIndicator({ +export function LogsScrollIndicator({ live, toggleLive, }: { @@ -20,17 +20,24 @@ export default function LogsScrollIndicator({ }) { const theme = useTheme() return ( - + + ) } diff --git a/assets/src/components/utils/IconExpander.tsx b/assets/src/components/utils/IconExpander.tsx index c1de20caf..7313624cc 100644 --- a/assets/src/components/utils/IconExpander.tsx +++ b/assets/src/components/utils/IconExpander.tsx @@ -144,16 +144,13 @@ export function ExpandedInput({ } & ComponentProps) { const inputRef = useRef(undefined) - useEffect(() => { - // only using querySelector because honorable input refs point to the div wrapper around the input - inputRef.current?.querySelector('input')?.focus() - }, []) + useEffect(() => inputRef.current?.focus(), []) return ( { return dayjs(date).format('MMM D, YYYY h:mm a') } +export const toISOStringOrUndef = (date: DateParam) => { + if (!date) return undefined + const dateObj = dayjs(date) + return dateObj.isValid() ? dateObj.toISOString() : undefined +} + export const isSameDay = (date: DateParam) => date ? dayjs(date).isSame(dayjs(), 'day') : false @@ -116,3 +122,12 @@ export const formatLocalizedDateTime = (date: DateParam) => { if (!date) return '' return dayjs(date).format('lll') } + +export const isValidDateTime = ( + date: DateParam, + format?: string, + strict: boolean = false +): boolean => { + if (!date) return false + return dayjs(date, format, strict).isValid() +}