feat(logs): add LogTimeRangePicker component and integrate time range selection in LogsViewer

This commit is contained in:
Nawaz Dhandala
2026-03-07 13:23:26 +00:00
parent e30f3b4ef2
commit 4871342e55
4 changed files with 310 additions and 30 deletions

View File

@@ -38,6 +38,11 @@ import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
import { JSONObject } from "Common/Types/JSON";
import { APP_API_URL } from "Common/UI/Config";
import ProjectUtil from "Common/UI/Utils/Project";
import RangeStartAndEndDateTime, {
RangeStartAndEndDateTimeUtil,
} from "Common/Types/Time/RangeStartAndEndDateTime";
import TimeRange from "Common/Types/Time/TimeRange";
import InBetween from "Common/Types/BaseDatabase/InBetween";
export interface ComponentProps {
id: string;
@@ -53,7 +58,6 @@ export interface ComponentProps {
const DEFAULT_PAGE_SIZE: number = 100;
const LIVE_POLL_INTERVAL_MS: number = 10000;
const DEFAULT_HISTOGRAM_HOURS: number = 1;
function buildBaseQuery(props: ComponentProps): Query<Log> {
const query: Query<Log> = {};
@@ -111,9 +115,18 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
const [logs, setLogs] = useState<Array<Log>>([]);
const [error, setError] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [filterOptions, setFilterOptions] = useState<Query<Log>>(
buildBaseQuery(props),
);
const [filterOptions, setFilterOptions] = useState<Query<Log>>(() => {
const base: Query<Log> = buildBaseQuery(props);
const defaultRange: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate({
range: TimeRange.PAST_ONE_HOUR,
});
(base as any).time = new InBetween<Date>(
defaultRange.startValue,
defaultRange.endValue,
);
return base;
});
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(
props.limit || DEFAULT_PAGE_SIZE,
@@ -140,8 +153,20 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
Map<string, Set<string>>
>(new Map());
// Time range state — single source of truth for histogram, facets, and log query
const [timeRange, setTimeRange] = useState<RangeStartAndEndDateTime>({
range: TimeRange.PAST_ONE_HOUR,
});
useEffect(() => {
setFilterOptions(buildBaseQuery(props));
const base: Query<Log> = buildBaseQuery(props);
const dateRange: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange);
(base as any).time = new InBetween<Date>(
dateRange.startValue,
dateRange.endValue,
);
setFilterOptions(base);
setPage(1);
}, [props.serviceIds, props.traceIds, props.spanIds, props.logQuery]);
@@ -235,14 +260,13 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
try {
setHistogramLoading(true);
const now: Date = new Date();
const startTime: Date = new Date(
now.getTime() - DEFAULT_HISTOGRAM_HOURS * 60 * 60 * 1000,
);
// Compute fresh dates from time range (preset ranges are relative to "now")
const dateRange: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange);
const requestData: JSONObject = {
startTime: startTime.toISOString(),
endTime: now.toISOString(),
startTime: dateRange.startValue.toISOString(),
endTime: dateRange.endValue.toISOString(),
} as JSONObject;
if (serviceIdStrings) {
@@ -280,7 +304,7 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
} finally {
setHistogramLoading(false);
}
}, [serviceIdStrings, appliedFacetFilters]);
}, [serviceIdStrings, appliedFacetFilters, timeRange]);
// --- Fetch facets ---
@@ -288,14 +312,13 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
try {
setFacetLoading(true);
const now: Date = new Date();
const startTime: Date = new Date(
now.getTime() - DEFAULT_HISTOGRAM_HOURS * 60 * 60 * 1000,
);
// Compute fresh dates from time range (preset ranges are relative to "now")
const dateRange: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange);
const requestData: JSONObject = {
startTime: startTime.toISOString(),
endTime: now.toISOString(),
startTime: dateRange.startValue.toISOString(),
endTime: dateRange.endValue.toISOString(),
facetKeys: ["severityText", "serviceId"],
} as JSONObject;
@@ -318,7 +341,7 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
} finally {
setFacetLoading(false);
}
}, [serviceIdStrings]);
}, [serviceIdStrings, timeRange]);
// --- Effects ---
@@ -471,12 +494,35 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
const handleHistogramTimeRangeSelect = useCallback(
(startTime: Date, endTime: Date): void => {
// Sync the time range picker to show "Custom" with selected dates
const customRange: RangeStartAndEndDateTime = {
range: TimeRange.CUSTOM,
startAndEndDate: new InBetween<Date>(startTime, endTime),
};
setTimeRange(customRange);
const updatedFilter: Query<Log> = {
...filterOptions,
time: {
startTime,
endTime,
} as any,
time: new InBetween<Date>(startTime, endTime),
};
setFilterOptions(updatedFilter);
setPage(1);
disableLiveMode();
},
[filterOptions, disableLiveMode],
);
const handleTimeRangeChange = useCallback(
(newTimeRange: RangeStartAndEndDateTime): void => {
setTimeRange(newTimeRange);
const dateRange: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate(newTimeRange);
const updatedFilter: Query<Log> = {
...filterOptions,
time: new InBetween<Date>(dateRange.startValue, dateRange.endValue),
};
setFilterOptions(updatedFilter);
@@ -490,6 +536,14 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
(facets: Map<string, Set<string>>): Query<Log> => {
const updatedFilter: Query<Log> = buildBaseQuery(props);
// Preserve the current time filter
const dateRange: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange);
(updatedFilter as any).time = new InBetween<Date>(
dateRange.startValue,
dateRange.endValue,
);
for (const [key, values] of facets.entries()) {
if (values.size === 0) {
continue;
@@ -507,7 +561,7 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
return updatedFilter;
},
[props],
[props, timeRange],
);
const handleFacetInclude = useCallback(
@@ -582,10 +636,17 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
const handleClearAllFilters = useCallback((): void => {
setAppliedFacetFilters(new Map());
setFilterOptions(buildBaseQuery(props));
const base: Query<Log> = buildBaseQuery(props);
const dateRange: InBetween<Date> =
RangeStartAndEndDateTimeUtil.getStartAndEndDate(timeRange);
(base as any).time = new InBetween<Date>(
dateRange.startValue,
dateRange.endValue,
);
setFilterOptions(base);
setPage(1);
disableLiveMode();
}, [props, disableLiveMode]);
}, [props, timeRange, disableLiveMode]);
const getTraceRoute = useCallback(
(traceId: string): Route | URL | undefined => {
@@ -731,6 +792,8 @@ const DashboardLogsViewer: FunctionComponent<ComponentProps> = (
onClearAllFilters={handleClearAllFilters}
valueSuggestions={valueSuggestions}
onFieldValueSelect={handleFieldValueSelect}
timeRange={timeRange}
onTimeRangeChange={handleTimeRangeChange}
/>
</div>
);

View File

@@ -44,6 +44,7 @@ import {
ActiveFilter,
} from "./types";
import { queryStringToFilter } from "../../../Types/Log/LogQueryToFilter";
import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
export interface ComponentProps {
logs: Array<Log>;
@@ -76,6 +77,8 @@ export interface ComponentProps {
onClearAllFilters?: (() => void) | undefined;
valueSuggestions?: Record<string, Array<string>> | undefined;
onFieldValueSelect?: ((fieldKey: string, value: string) => void) | undefined;
timeRange?: RangeStartAndEndDateTime | undefined;
onTimeRangeChange?: ((value: RangeStartAndEndDateTime) => void) | undefined;
}
export type LogsSortField = LogsTableSortField;
@@ -441,6 +444,9 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
currentPage,
totalPages,
...(props.liveOptions ? { liveOptions: props.liveOptions } : {}),
...(props.timeRange && props.onTimeRangeChange
? { timeRange: props.timeRange, onTimeRangeChange: props.onTimeRangeChange }
: {}),
};
const showSidebar: boolean =

View File

@@ -0,0 +1,202 @@
import React, {
FunctionComponent,
ReactElement,
useState,
useRef,
useEffect,
useCallback,
} from "react";
import Icon from "../../Icon/Icon";
import IconProp from "../../../../Types/Icon/IconProp";
import RangeStartAndEndDateTime from "../../../../Types/Time/RangeStartAndEndDateTime";
import TimeRange from "../../../../Types/Time/TimeRange";
import InBetween from "../../../../Types/BaseDatabase/InBetween";
import StartAndEndDate, {
StartAndEndDateType,
} from "../../Date/StartAndEndDate";
export interface LogTimeRangePickerProps {
value: RangeStartAndEndDateTime;
onChange: (value: RangeStartAndEndDateTime) => void;
}
// Preset options to show in the dropdown (ordered for log investigation use)
const PRESET_OPTIONS: Array<{ range: TimeRange; label: string }> = [
{ range: TimeRange.PAST_THIRTY_MINS, label: "Past 30 Minutes" },
{ range: TimeRange.PAST_ONE_HOUR, label: "Past 1 Hour" },
{ range: TimeRange.PAST_TWO_HOURS, label: "Past 2 Hours" },
{ range: TimeRange.PAST_THREE_HOURS, label: "Past 3 Hours" },
{ range: TimeRange.PAST_ONE_DAY, label: "Past 1 Day" },
{ range: TimeRange.PAST_TWO_DAYS, label: "Past 2 Days" },
{ range: TimeRange.PAST_ONE_WEEK, label: "Past 1 Week" },
{ range: TimeRange.PAST_TWO_WEEKS, label: "Past 2 Weeks" },
{ range: TimeRange.PAST_ONE_MONTH, label: "Past 1 Month" },
{ range: TimeRange.PAST_THREE_MONTHS, label: "Past 3 Months" },
];
function formatDateShort(date: Date): string {
const month: string = date.toLocaleString("en-US", { month: "short" });
const day: number = date.getDate();
const hours: string = date.getHours().toString().padStart(2, "0");
const minutes: string = date.getMinutes().toString().padStart(2, "0");
return `${month} ${day}, ${hours}:${minutes}`;
}
function getButtonLabel(value: RangeStartAndEndDateTime): string {
if (value.range === TimeRange.CUSTOM && value.startAndEndDate) {
const start: string = formatDateShort(value.startAndEndDate.startValue);
const end: string = formatDateShort(value.startAndEndDate.endValue);
return `${start} ${end}`;
}
const preset: { range: TimeRange; label: string } | undefined =
PRESET_OPTIONS.find(
(opt: { range: TimeRange; label: string }) =>
opt.range === value.range,
);
return preset ? preset.label : value.range;
}
const LogTimeRangePicker: FunctionComponent<LogTimeRangePickerProps> = (
props: LogTimeRangePickerProps,
): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [showCustom, setShowCustom] = useState<boolean>(
props.value.range === TimeRange.CUSTOM,
);
const containerRef: React.RefObject<HTMLDivElement> =
useRef<HTMLDivElement>(null!);
// Close on click outside
useEffect(() => {
const handleClickOutside: (e: MouseEvent) => void = (
e: MouseEvent,
): void => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
// Sync showCustom when value changes externally (e.g., histogram drag)
useEffect(() => {
setShowCustom(props.value.range === TimeRange.CUSTOM);
}, [props.value.range]);
const handlePresetSelect = useCallback(
(range: TimeRange): void => {
props.onChange({ range });
setShowCustom(false);
setIsOpen(false);
},
[props],
);
const handleCustomDateChange = useCallback(
(dateRange: InBetween<Date> | null): void => {
if (dateRange) {
props.onChange({
range: TimeRange.CUSTOM,
startAndEndDate: dateRange,
});
}
},
[props],
);
const buttonLabel: string = getButtonLabel(props.value);
return (
<div ref={containerRef} className="relative">
<button
type="button"
className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
isOpen
? "border-indigo-300 bg-indigo-50 text-indigo-700"
: "border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50"
}`}
onClick={() => {
setIsOpen(!isOpen);
}}
>
<Icon
icon={IconProp.Clock}
className="h-3.5 w-3.5"
/>
<span>{buttonLabel}</span>
<Icon
icon={IconProp.ChevronDown}
className={`h-3 w-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
/>
</button>
{isOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-72 rounded-lg border border-gray-200 bg-white shadow-lg">
{/* Preset options */}
<div className="max-h-64 overflow-y-auto py-1">
{PRESET_OPTIONS.map(
(option: { range: TimeRange; label: string }) => {
const isActive: boolean =
props.value.range === option.range;
return (
<button
key={option.range}
type="button"
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors ${
isActive
? "bg-indigo-50 font-medium text-indigo-700"
: "text-gray-700 hover:bg-gray-50"
}`}
onClick={() => {
handlePresetSelect(option.range);
}}
>
{option.label}
</button>
);
},
)}
{/* Custom option */}
<button
type="button"
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors ${
props.value.range === TimeRange.CUSTOM
? "bg-indigo-50 font-medium text-indigo-700"
: "text-gray-700 hover:bg-gray-50"
}`}
onClick={() => {
setShowCustom(true);
}}
>
Custom Range...
</button>
</div>
{/* Custom date inputs */}
{showCustom && (
<div className="border-t border-gray-100 p-3">
<StartAndEndDate
type={StartAndEndDateType.DateTime}
value={props.value.startAndEndDate}
hideTimeButtons={true}
onValueChanged={handleCustomDateChange}
/>
</div>
)}
</div>
)}
</div>
);
};
export default LogTimeRangePicker;

View File

@@ -1,6 +1,8 @@
import React, { FunctionComponent, ReactElement } from "react";
import LiveLogsToggle from "./LiveLogsToggle";
import LogTimeRangePicker from "./LogTimeRangePicker";
import { LiveLogsOptions } from "../types";
import RangeStartAndEndDateTime from "../../../../Types/Time/RangeStartAndEndDateTime";
export interface LogsViewerToolbarProps {
resultCount: number;
@@ -8,6 +10,8 @@ export interface LogsViewerToolbarProps {
totalPages?: number;
className?: string;
liveOptions?: LiveLogsOptions;
timeRange?: RangeStartAndEndDateTime;
onTimeRangeChange?: (value: RangeStartAndEndDateTime) => void;
}
const LogsViewerToolbar: FunctionComponent<LogsViewerToolbarProps> = (
@@ -20,7 +24,7 @@ const LogsViewerToolbar: FunctionComponent<LogsViewerToolbarProps> = (
return (
<div
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between ${props.className || ""}`}
className={`flex items-center justify-between gap-3 ${props.className || ""}`}
>
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500">
<span className="font-medium text-gray-700">
@@ -34,10 +38,15 @@ const LogsViewerToolbar: FunctionComponent<LogsViewerToolbarProps> = (
)}
</div>
{props.timeRange && props.onTimeRangeChange && (
<LogTimeRangePicker
value={props.timeRange}
onChange={props.onTimeRangeChange}
/>
)}
{props.liveOptions && (
<div className="flex flex-wrap items-center gap-2">
<LiveLogsToggle {...props.liveOptions} />
</div>
<LiveLogsToggle {...props.liveOptions} />
)}
</div>
);