mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat(logs): add LogTimeRangePicker component and integrate time range selection in LogsViewer
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user