Skip to content

Commit 8634a28

Browse files
committed
app/vmui: auto-sync time picker with query _time via /select/logsql/query_time_range (#558)
1 parent 6e49f64 commit 8634a28

File tree

12 files changed

+274
-18
lines changed

12 files changed

+274
-18
lines changed

app/vlselect/logsql/logsql.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ var (
5050
// {
5151
// "start":"YYYY-MM-DDThh:mm:sss.nnnnnnnnnZ",
5252
// "end":"YYYY-MM-DDThh:mm:sss.nnnnnnnnnZ",
53+
// "hasTimeFilter":true|false
5354
// }
5455
func ProcessQueryTimeRangeRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) {
55-
minTimestamp, maxTimestamp, err := parseQueryTimeRangeArgs(r)
56+
minTimestamp, maxTimestamp, hasTimeFilter, err := parseQueryTimeRangeArgs(r)
5657
if err != nil {
5758
httpserver.Errorf(w, r, "%s", err)
5859
return
@@ -62,25 +63,29 @@ func ProcessQueryTimeRangeRequest(ctx context.Context, w http.ResponseWriter, r
6263

6364
startStr := timestampToRFC3339Nano(minTimestamp)
6465
endStr := timestampToRFC3339Nano(maxTimestamp)
65-
fmt.Fprintf(w, `{"start":%q,"end":%q}`, startStr, endStr)
66+
fmt.Fprintf(w, `{"start":%q,"end":%q,"hasTimeFilter":%t}`, startStr, endStr, hasTimeFilter)
6667
}
6768

68-
func parseQueryTimeRangeArgs(r *http.Request) (int64, int64, error) {
69+
func parseQueryTimeRangeArgs(r *http.Request) (int64, int64, bool, error) {
6970
qStr := r.FormValue("query")
7071
if qStr == "" {
71-
return 0, 0, fmt.Errorf("`query` arg cannot be empty")
72+
return 0, 0, false, fmt.Errorf("`query` arg cannot be empty")
7273
}
7374
currTimestamp := time.Now().UnixNano()
7475
q, err := logstorage.ParseQueryAtTimestamp(qStr, currTimestamp)
7576
if err != nil {
76-
return 0, 0, fmt.Errorf("cannot parse query [%s]: %s", qStr, err)
77+
return 0, 0, false, fmt.Errorf("cannot parse query [%s]: %s", qStr, err)
7778
}
7879

7980
minTimestamp, maxTimestamp := q.GetFilterTimeRange()
81+
82+
// hasTimeFilter is true if the query itself contains a _time filter
83+
hasTimeFilter := (minTimestamp != math.MinInt64 || maxTimestamp != math.MaxInt64)
84+
8085
if minTimestamp == math.MinInt64 {
8186
start, ok, err := getTimeNsec(r, "start")
8287
if err != nil {
83-
return 0, 0, err
88+
return 0, 0, false, err
8489
}
8590
if ok {
8691
minTimestamp = start
@@ -89,14 +94,14 @@ func parseQueryTimeRangeArgs(r *http.Request) (int64, int64, error) {
8994
if maxTimestamp == math.MaxInt64 {
9095
end, ok, err := getTimeNsec(r, "end")
9196
if err != nil {
92-
return 0, 0, err
97+
return 0, 0, false, err
9398
}
9499
if ok {
95100
maxTimestamp = end
96101
}
97102
}
98103

99-
return minTimestamp, maxTimestamp, nil
104+
return minTimestamp, maxTimestamp, hasTimeFilter, nil
100105
}
101106

102107
func timestampToRFC3339Nano(nsec int64) string {

app/vmui/packages/vmui/src/components/Configurators/GlobalSettings/GlobalSettings.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import TimezonesPicker from "./Timezones/TimezonesPicker";
1010
import ThemeControl from "../ThemeControl/ThemeControl";
1111
import useDeviceDetect from "../../../hooks/useDeviceDetect";
1212
import useBoolean from "../../../hooks/useBoolean";
13+
import QueryTimeOverride from "./QueryTimeOverride/QueryTimeOverride";
1314

1415
const title = "Settings";
1516

@@ -48,6 +49,10 @@ const GlobalSettings = forwardRef<GlobalSettingsHandle>((_, ref) => {
4849
show: true,
4950
component: <TimezonesPicker ref={timezoneSettingRef}/>
5051
},
52+
{
53+
show: true,
54+
component: <QueryTimeOverride/>
55+
},
5156
{
5257
show: !appModeEnable,
5358
component: <ThemeControl/>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { FC } from "preact/compat";
2+
import Switch from "../../../Main/Switch/Switch";
3+
import { getFromStorage, saveToStorage } from "../../../../utils/storage";
4+
import { useLocalStorageBoolean } from "../../../../hooks/useLocalStorageBoolean";
5+
6+
const key = "LOGS_OVERRIDE_TIME";
7+
const defaultValue = true;
8+
9+
export const getOverrideValue = () => {
10+
const value = getFromStorage(key);
11+
if (!value) return defaultValue;
12+
return value === "true";
13+
};
14+
15+
const QueryTimeOverride: FC = () => {
16+
const [overrideTime, setOverrideTime] = useLocalStorageBoolean(key, getOverrideValue);
17+
18+
const handleUpdateValue = (value: boolean) => {
19+
setOverrideTime(value);
20+
saveToStorage(key, String(value));
21+
};
22+
23+
return (
24+
<div>
25+
<div className="vm-server-configurator__title">
26+
Query time override
27+
</div>
28+
<Switch
29+
label="Override time picker with query _time filter"
30+
value={overrideTime}
31+
onChange={handleUpdateValue}
32+
/>
33+
</div>
34+
);
35+
};
36+
37+
export default QueryTimeOverride;

app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeSelector/TimeSelector.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import DateTimeInput from "../../../Main/DatePicker/DateTimeInput/DateTimeInput"
1818
import useBoolean from "../../../../hooks/useBoolean";
1919
import useWindowSize from "../../../../hooks/useWindowSize";
2020
import usePrevious from "../../../../hooks/usePrevious";
21+
import { useQueryState } from "../../../../state/query/QueryStateContext";
2122

2223
type Props = {
2324
onOpenSettings?: () => void;
@@ -26,6 +27,8 @@ type Props = {
2627
export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
2728
const { isMobile } = useDeviceDetect();
2829
const { isDarkTheme } = useAppState();
30+
const { queryHasTimeFilter } = useQueryState();
31+
2932
const wrapperRef = useRef<HTMLDivElement>(null);
3033
const documentSize = useWindowSize();
3134
const displayFullDate = useMemo(() => documentSize.width > 1120, [documentSize]);
@@ -95,7 +98,7 @@ export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
9598
handleCloseOptions();
9699
};
97100

98-
const handleClickTimezone = () => {
101+
const handleOpenSettings = () => {
99102
onOpenSettings && onOpenSettings();
100103
handleCloseOptions();
101104
};
@@ -164,6 +167,19 @@ export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
164167
})}
165168
ref={wrapperRef}
166169
>
170+
{queryHasTimeFilter && (
171+
<div className="vm-time-selector-warning">
172+
<p>Time range is overridden by the query `_time` filter.</p>
173+
<p>Remove `_time` from the query to use manual selection.</p>
174+
<p
175+
className="vm-time-selector-warning__interactive"
176+
onClick={handleOpenSettings}
177+
>
178+
To disable query time override in settings, click here.
179+
</p>
180+
</div>
181+
)}
182+
167183
<div className="vm-time-selector-left">
168184
<div
169185
className={classNames({
@@ -190,7 +206,7 @@ export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
190206
</div>
191207
<div
192208
className="vm-time-selector-left-timezone"
193-
onClick={handleClickTimezone}
209+
onClick={handleOpenSettings}
194210
>
195211
<div className="vm-time-selector-left-timezone__title">{activeTimezone.region}</div>
196212
<div className="vm-time-selector-left-timezone__utc">{activeTimezone.utc}</div>

app/vmui/packages/vmui/src/components/Configurators/TimeRangeSettings/TimeSelector/style.scss

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,27 @@
6565
gap: $padding-small;
6666
}
6767
}
68+
69+
&-warning {
70+
display: flex;
71+
flex-direction: column;
72+
gap: 2px;
73+
grid-column: 1/span 2;
74+
color: $color-warning;
75+
padding: 0 $padding-global $padding-global;
76+
border-bottom: $border-divider;
77+
margin-bottom: $padding-global;
78+
line-height: 1.3;
79+
font-size: $font-size-small;
80+
81+
&__interactive {
82+
cursor: pointer;
83+
transition: filter 0.2s;
84+
text-decoration: underline;
85+
86+
&:hover {
87+
filter: brightness(0.8);
88+
}
89+
}
90+
}
6891
}

app/vmui/packages/vmui/src/hooks/useLocalStorageBoolean.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,28 @@ describe("useLocalStorageBoolean", () => {
6767

6868
expect(result.current[0]).toBe(false);
6969
});
70+
71+
it("uses customGetter instead of getFromStorage and reacts to storage events", () => {
72+
const mockGetFromStorage = getFromStorage as Mock;
73+
const customGetter = vi.fn(() => true);
74+
75+
const { result } = renderHook(() =>
76+
useLocalStorageBoolean(testStorageKey, customGetter)
77+
);
78+
79+
expect(customGetter).toHaveBeenCalledWith(testStorageKey);
80+
expect(result.current[0]).toBe(true);
81+
82+
expect(mockGetFromStorage).not.toHaveBeenCalled();
83+
84+
(customGetter as Mock).mockReturnValueOnce(false);
85+
86+
act(() => {
87+
window.dispatchEvent(
88+
new StorageEvent("storage", { key: testStorageKey, newValue: "false" })
89+
);
90+
});
91+
92+
expect(result.current[0]).toBe(false);
93+
});
7094
});

app/vmui/packages/vmui/src/hooks/useLocalStorageBoolean.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,27 @@ import useEventListener from "./useEventListener";
66
* A custom hook that synchronizes a boolean state with a value stored in localStorage.
77
*
88
* @param {StorageKeys} key - The key used to access the corresponding value in localStorage.
9+
* @param {(key: StorageKeys) => boolean} [customGetter] - Optional custom getter function used to read
910
* @returns {[boolean, function]} A tuple containing the current boolean value from localStorage and a setter function to update the value in localStorage.
1011
*
1112
* The hook listens to the "storage" event to automatically update the state when the localStorage value changes.
1213
*/
13-
export const useLocalStorageBoolean = (key: StorageKeys): [boolean, (value: boolean) => void] => {
14-
const [value, setValue] = useState(!!getFromStorage(key));
14+
export const useLocalStorageBoolean = (
15+
key: StorageKeys,
16+
customGetter?: (key: StorageKeys) => boolean
17+
): [boolean, (value: boolean) => void] => {
18+
const getter = useCallback((key: StorageKeys) => {
19+
return customGetter ? customGetter(key) : !!getFromStorage(key);
20+
}, [key, customGetter]);
21+
22+
const [value, setValue] = useState(getter(key));
1523

1624
const handleUpdateStorage = useCallback(() => {
17-
const newValue = !!getFromStorage(key);
25+
const newValue = getter(key);
1826
if (newValue !== value) {
1927
setValue(newValue);
2028
}
21-
}, [key, value]);
29+
}, [key, value, getter]);
2230

2331
const setNewValue = useCallback((newValue: boolean) => {
2432
saveToStorage(key, newValue);

app/vmui/packages/vmui/src/pages/QueryPage/QueryPage.tsx

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Alert from "../../components/Main/Alert/Alert";
77
import QueryPageHeader from "./QueryPageHeader/QueryPageHeader";
88
import "./style.scss";
99
import { ErrorTypes, TimeParams } from "../../types";
10-
import { useTimeState } from "../../state/time/TimeStateContext";
10+
import { useTimeDispatch, useTimeState } from "../../state/time/TimeStateContext";
1111
import { getFromStorage, saveToStorage } from "../../utils/storage";
1212
import HitsChart from "./HitsChart/HitsChart";
1313
import { useFetchLogHits } from "./hooks/useFetchLogHits";
@@ -23,16 +23,19 @@ import { ExtraFilter } from "../OverviewPage/FiltersBar/types";
2323
import { useHitsChartConfig } from "./HitsChart/hooks/useHitsChartConfig";
2424
import { useLimitGuard } from "./LimitController/useLimitGuard";
2525
import LimitConfirmModal from "./LimitController/LimitConfirmModal";
26+
import { useFetchQueryTime } from "./hooks/useFetchQueryTime";
27+
import { getOverrideValue } from "../../components/Configurators/GlobalSettings/QueryTimeOverride/QueryTimeOverride";
2628

2729
const storageLimit = Number(getFromStorage("LOGS_LIMIT"));
2830
const defaultLimit = isNaN(storageLimit) ? LOGS_DEFAULT_LIMIT : storageLimit;
2931

3032
type FetchFlags = { logs: boolean; hits: boolean };
3133

3234
const QueryPage: FC = () => {
33-
const { queryHistory } = useQueryState();
35+
const { queryHistory, queryHasTimeFilter } = useQueryState();
3436
const queryDispatch = useQueryDispatch();
3537
const { duration, relativeTime, period: periodState } = useTimeState();
38+
const timeDispatch = useTimeDispatch();
3639
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
3740
const { topHits, groupFieldHits } = useHitsChartConfig();
3841
const prevTopHits = usePrevious(topHits);
@@ -49,6 +52,8 @@ const QueryPage: FC = () => {
4952
const [limit, setLimit] = useStateSearchParams(defaultLimit, LOGS_URL_PARAMS.LIMIT);
5053
const [query, setQuery] = useStateSearchParams("*", "query");
5154

55+
const [skipNextPeriodEffect, setSkipNextPeriodEffect] = useState(false);
56+
5257
const handleChangeLimit = (limit: number) => {
5358
setLimit(limit);
5459
setSearchParamsFromKeys({ limit });
@@ -74,6 +79,7 @@ const QueryPage: FC = () => {
7479

7580
const { logs, isLoading, error, fetchLogs, abortController, durationMs: queryDuration, queryParams } = useFetchLogs(query, limit);
7681
const { fetchLogHits, ...dataLogHits } = useFetchLogHits(query);
82+
const { fetchQueryTime } = useFetchQueryTime(query);
7783

7884
const fetchData = async (period: TimeParams, flags: FetchFlags) => {
7985
if (flags.logs) {
@@ -95,14 +101,32 @@ const QueryPage: FC = () => {
95101
return getTimeperiodForDuration(duration, until());
96102
};
97103

98-
const handleRunQuery = () => {
104+
const handleRunQuery = async () => {
99105
if (!query) {
100106
setQueryError(ErrorTypes.validQuery);
101107
return;
102108
}
103109
setQueryError("");
104110

105-
const newPeriod = getPeriod();
111+
const uiPeriod = getPeriod();
112+
const apiPeriod = getOverrideValue()
113+
? await fetchQueryTime({ query, period: uiPeriod })
114+
: undefined;
115+
116+
const newPeriod = apiPeriod ?? uiPeriod;
117+
118+
queryDispatch({ type: "SET_QUERY_HAS_TIME_FILTER", payload: !!apiPeriod?.hasTimeFilter });
119+
if (apiPeriod?.hasTimeFilter) {
120+
setSkipNextPeriodEffect(true);
121+
timeDispatch({
122+
type: "SET_PERIOD",
123+
payload: {
124+
from: new Date(newPeriod.start * 1000),
125+
to: new Date(newPeriod.end * 1000)
126+
}
127+
});
128+
}
129+
106130
setPeriod(newPeriod);
107131
dataLogHits.abortController.abort?.();
108132
abortController.abort?.();
@@ -115,6 +139,7 @@ const QueryPage: FC = () => {
115139
});
116140
updateHistory();
117141
};
142+
118143
const handleApplyFilter = (val: ExtraFilter) => {
119144
setQuery(prev => `${filterToExpr(val)} AND ${prev}`);
120145
setIsUpdatingQuery(true);
@@ -131,6 +156,10 @@ const QueryPage: FC = () => {
131156

132157
useEffect(() => {
133158
if (!query) return;
159+
if (skipNextPeriodEffect) {
160+
setSkipNextPeriodEffect(false);
161+
return;
162+
}
134163
handleRunQuery();
135164
}, [periodState]);
136165

@@ -174,6 +203,13 @@ const QueryPage: FC = () => {
174203
isLoading={isLoading || dataLogHits.isLoading}
175204
/>
176205
{error && <Alert variant="error">{error}</Alert>}
206+
{queryHasTimeFilter && <Alert variant="warning">
207+
<p>
208+
Time range is overridden by the query `_time` filter.
209+
Remove `_time` from the query to use manual selection.
210+
Disable query time override in Settings.
211+
</p>
212+
</Alert>}
177213
{!error && (
178214
<HitsChart
179215
{...dataLogHits}

0 commit comments

Comments
 (0)