Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions app/vlselect/logsql/logsql.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ var (
// {
// "start":"YYYY-MM-DDThh:mm:sss.nnnnnnnnnZ",
// "end":"YYYY-MM-DDThh:mm:sss.nnnnnnnnnZ",
// "hasTimeFilter":true|false
// }
func ProcessQueryTimeRangeRequest(ctx context.Context, w http.ResponseWriter, r *http.Request) {
minTimestamp, maxTimestamp, err := parseQueryTimeRangeArgs(r)
minTimestamp, maxTimestamp, hasTimeFilter, err := parseQueryTimeRangeArgs(r)
if err != nil {
httpserver.Errorf(w, r, "%s", err)
return
Expand All @@ -62,25 +63,29 @@ func ProcessQueryTimeRangeRequest(ctx context.Context, w http.ResponseWriter, r

startStr := timestampToRFC3339Nano(minTimestamp)
endStr := timestampToRFC3339Nano(maxTimestamp)
fmt.Fprintf(w, `{"start":%q,"end":%q}`, startStr, endStr)
fmt.Fprintf(w, `{"start":%q,"end":%q,"hasTimeFilter":%t}`, startStr, endStr, hasTimeFilter)
}

func parseQueryTimeRangeArgs(r *http.Request) (int64, int64, error) {
func parseQueryTimeRangeArgs(r *http.Request) (int64, int64, bool, error) {
qStr := r.FormValue("query")
if qStr == "" {
return 0, 0, fmt.Errorf("`query` arg cannot be empty")
return 0, 0, false, fmt.Errorf("`query` arg cannot be empty")
}
currTimestamp := time.Now().UnixNano()
q, err := logstorage.ParseQueryAtTimestamp(qStr, currTimestamp)
if err != nil {
return 0, 0, fmt.Errorf("cannot parse query [%s]: %s", qStr, err)
return 0, 0, false, fmt.Errorf("cannot parse query [%s]: %s", qStr, err)
}

minTimestamp, maxTimestamp := q.GetFilterTimeRange()

// hasTimeFilter is true if the query itself contains a _time filter
hasTimeFilter := (minTimestamp != math.MinInt64 || maxTimestamp != math.MaxInt64)

if minTimestamp == math.MinInt64 {
start, ok, err := getTimeNsec(r, "start")
if err != nil {
return 0, 0, err
return 0, 0, false, err
}
if ok {
minTimestamp = start
Expand All @@ -89,14 +94,14 @@ func parseQueryTimeRangeArgs(r *http.Request) (int64, int64, error) {
if maxTimestamp == math.MaxInt64 {
end, ok, err := getTimeNsec(r, "end")
if err != nil {
return 0, 0, err
return 0, 0, false, err
}
if ok {
maxTimestamp = end
}
}

return minTimestamp, maxTimestamp, nil
return minTimestamp, maxTimestamp, hasTimeFilter, nil
}

func timestampToRFC3339Nano(nsec int64) string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import TimezonesPicker from "./Timezones/TimezonesPicker";
import ThemeControl from "../ThemeControl/ThemeControl";
import useDeviceDetect from "../../../hooks/useDeviceDetect";
import useBoolean from "../../../hooks/useBoolean";
import QueryTimeOverride from "./QueryTimeOverride/QueryTimeOverride";

const title = "Settings";

Expand Down Expand Up @@ -48,6 +49,10 @@ const GlobalSettings = forwardRef<GlobalSettingsHandle>((_, ref) => {
show: true,
component: <TimezonesPicker ref={timezoneSettingRef}/>
},
{
show: true,
component: <QueryTimeOverride/>
},
{
show: !appModeEnable,
component: <ThemeControl/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { FC } from "preact/compat";
import Switch from "../../../Main/Switch/Switch";
import { getFromStorage, saveToStorage } from "../../../../utils/storage";
import { useLocalStorageBoolean } from "../../../../hooks/useLocalStorageBoolean";

const key = "LOGS_OVERRIDE_TIME";
const defaultValue = true;

export const getOverrideValue = () => {
const value = getFromStorage(key);
if (!value) return defaultValue;
return value === "true";
};

const QueryTimeOverride: FC = () => {
const [overrideTime, setOverrideTime] = useLocalStorageBoolean(key, getOverrideValue);

const handleUpdateValue = (value: boolean) => {
setOverrideTime(value);
saveToStorage(key, String(value));
};

return (
<div>
<div className="vm-server-configurator__title">
Query time override
</div>
<Switch
label="Override time picker with query _time filter"
value={overrideTime}
onChange={handleUpdateValue}
/>
</div>
);
};

export default QueryTimeOverride;
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import DateTimeInput from "../../../Main/DatePicker/DateTimeInput/DateTimeInput"
import useBoolean from "../../../../hooks/useBoolean";
import useWindowSize from "../../../../hooks/useWindowSize";
import usePrevious from "../../../../hooks/usePrevious";
import { useQueryState } from "../../../../state/query/QueryStateContext";

type Props = {
onOpenSettings?: () => void;
Expand All @@ -26,6 +27,8 @@ type Props = {
export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
const { isMobile } = useDeviceDetect();
const { isDarkTheme } = useAppState();
const { queryHasTimeFilter } = useQueryState();

const wrapperRef = useRef<HTMLDivElement>(null);
const documentSize = useWindowSize();
const displayFullDate = useMemo(() => documentSize.width > 1120, [documentSize]);
Expand Down Expand Up @@ -95,7 +98,7 @@ export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
handleCloseOptions();
};

const handleClickTimezone = () => {
const handleOpenSettings = () => {
onOpenSettings && onOpenSettings();
handleCloseOptions();
};
Expand Down Expand Up @@ -164,6 +167,19 @@ export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
})}
ref={wrapperRef}
>
{queryHasTimeFilter && (
<div className="vm-time-selector-warning">
<p>Time range is overridden by the query `_time` filter.</p>
<p>Remove `_time` from the query to use manual selection.</p>
<p
className="vm-time-selector-warning__interactive"
onClick={handleOpenSettings}
>
To disable query time override in settings, click here.
</p>
</div>
)}

<div className="vm-time-selector-left">
<div
className={classNames({
Expand All @@ -190,7 +206,7 @@ export const TimeSelector: FC<Props> = ({ onOpenSettings }) => {
</div>
<div
className="vm-time-selector-left-timezone"
onClick={handleClickTimezone}
onClick={handleOpenSettings}
>
<div className="vm-time-selector-left-timezone__title">{activeTimezone.region}</div>
<div className="vm-time-selector-left-timezone__utc">{activeTimezone.utc}</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,27 @@
gap: $padding-small;
}
}

&-warning {
display: flex;
flex-direction: column;
gap: 2px;
grid-column: 1/span 2;
color: $color-warning;
padding: 0 $padding-global $padding-global;
border-bottom: $border-divider;
margin-bottom: $padding-global;
line-height: 1.3;
font-size: $font-size-small;

&__interactive {
cursor: pointer;
transition: filter 0.2s;
text-decoration: underline;

&:hover {
filter: brightness(0.8);
}
}
}
}
24 changes: 24 additions & 0 deletions app/vmui/packages/vmui/src/hooks/useLocalStorageBoolean.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,28 @@ describe("useLocalStorageBoolean", () => {

expect(result.current[0]).toBe(false);
});

it("uses customGetter instead of getFromStorage and reacts to storage events", () => {
const mockGetFromStorage = getFromStorage as Mock;
const customGetter = vi.fn(() => true);

const { result } = renderHook(() =>
useLocalStorageBoolean(testStorageKey, customGetter)
);

expect(customGetter).toHaveBeenCalledWith(testStorageKey);
expect(result.current[0]).toBe(true);

expect(mockGetFromStorage).not.toHaveBeenCalled();

(customGetter as Mock).mockReturnValueOnce(false);

act(() => {
window.dispatchEvent(
new StorageEvent("storage", { key: testStorageKey, newValue: "false" })
);
});

expect(result.current[0]).toBe(false);
});
});
16 changes: 12 additions & 4 deletions app/vmui/packages/vmui/src/hooks/useLocalStorageBoolean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,27 @@ import useEventListener from "./useEventListener";
* A custom hook that synchronizes a boolean state with a value stored in localStorage.
*
* @param {StorageKeys} key - The key used to access the corresponding value in localStorage.
* @param {(key: StorageKeys) => boolean} [customGetter] - Optional custom getter function used to read
* @returns {[boolean, function]} A tuple containing the current boolean value from localStorage and a setter function to update the value in localStorage.
*
* The hook listens to the "storage" event to automatically update the state when the localStorage value changes.
*/
export const useLocalStorageBoolean = (key: StorageKeys): [boolean, (value: boolean) => void] => {
const [value, setValue] = useState(!!getFromStorage(key));
export const useLocalStorageBoolean = (
key: StorageKeys,
customGetter?: (key: StorageKeys) => boolean
): [boolean, (value: boolean) => void] => {
const getter = useCallback((key: StorageKeys) => {
return customGetter ? customGetter(key) : !!getFromStorage(key);
}, [key, customGetter]);

const [value, setValue] = useState(getter(key));

const handleUpdateStorage = useCallback(() => {
const newValue = !!getFromStorage(key);
const newValue = getter(key);
if (newValue !== value) {
setValue(newValue);
}
}, [key, value]);
}, [key, value, getter]);

const setNewValue = useCallback((newValue: boolean) => {
saveToStorage(key, newValue);
Expand Down
44 changes: 40 additions & 4 deletions app/vmui/packages/vmui/src/pages/QueryPage/QueryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Alert from "../../components/Main/Alert/Alert";
import QueryPageHeader from "./QueryPageHeader/QueryPageHeader";
import "./style.scss";
import { ErrorTypes, TimeParams } from "../../types";
import { useTimeState } from "../../state/time/TimeStateContext";
import { useTimeDispatch, useTimeState } from "../../state/time/TimeStateContext";
import { getFromStorage, saveToStorage } from "../../utils/storage";
import HitsChart from "./HitsChart/HitsChart";
import { useFetchLogHits } from "./hooks/useFetchLogHits";
Expand All @@ -23,16 +23,19 @@ import { ExtraFilter } from "../OverviewPage/FiltersBar/types";
import { useHitsChartConfig } from "./HitsChart/hooks/useHitsChartConfig";
import { useLimitGuard } from "./LimitController/useLimitGuard";
import LimitConfirmModal from "./LimitController/LimitConfirmModal";
import { useFetchQueryTime } from "./hooks/useFetchQueryTime";
import { getOverrideValue } from "../../components/Configurators/GlobalSettings/QueryTimeOverride/QueryTimeOverride";

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

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

const QueryPage: FC = () => {
const { queryHistory } = useQueryState();
const { queryHistory, queryHasTimeFilter } = useQueryState();
const queryDispatch = useQueryDispatch();
const { duration, relativeTime, period: periodState } = useTimeState();
const timeDispatch = useTimeDispatch();
const { setSearchParamsFromKeys } = useSearchParamsFromObject();
const { topHits, groupFieldHits } = useHitsChartConfig();
const prevTopHits = usePrevious(topHits);
Expand All @@ -49,6 +52,8 @@ const QueryPage: FC = () => {
const [limit, setLimit] = useStateSearchParams(defaultLimit, LOGS_URL_PARAMS.LIMIT);
const [query, setQuery] = useStateSearchParams("*", "query");

const [skipNextPeriodEffect, setSkipNextPeriodEffect] = useState(false);

const handleChangeLimit = (limit: number) => {
setLimit(limit);
setSearchParamsFromKeys({ limit });
Expand All @@ -74,6 +79,7 @@ const QueryPage: FC = () => {

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

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

const handleRunQuery = () => {
const handleRunQuery = async () => {
if (!query) {
setQueryError(ErrorTypes.validQuery);
return;
}
setQueryError("");

const newPeriod = getPeriod();
const uiPeriod = getPeriod();
const apiPeriod = getOverrideValue()
? await fetchQueryTime({ query, period: uiPeriod })
: undefined;

const newPeriod = apiPeriod ?? uiPeriod;

queryDispatch({ type: "SET_QUERY_HAS_TIME_FILTER", payload: !!apiPeriod?.hasTimeFilter });
if (apiPeriod?.hasTimeFilter) {
setSkipNextPeriodEffect(true);
timeDispatch({
type: "SET_PERIOD",
payload: {
from: new Date(newPeriod.start * 1000),
to: new Date(newPeriod.end * 1000)
}
});
}

setPeriod(newPeriod);
dataLogHits.abortController.abort?.();
abortController.abort?.();
Expand All @@ -115,6 +139,7 @@ const QueryPage: FC = () => {
});
updateHistory();
};

const handleApplyFilter = (val: ExtraFilter) => {
setQuery(prev => `${filterToExpr(val)} AND ${prev}`);
setIsUpdatingQuery(true);
Expand All @@ -131,6 +156,10 @@ const QueryPage: FC = () => {

useEffect(() => {
if (!query) return;
if (skipNextPeriodEffect) {
setSkipNextPeriodEffect(false);
return;
}
handleRunQuery();
}, [periodState]);

Expand Down Expand Up @@ -174,6 +203,13 @@ const QueryPage: FC = () => {
isLoading={isLoading || dataLogHits.isLoading}
/>
{error && <Alert variant="error">{error}</Alert>}
{queryHasTimeFilter && <Alert variant="warning">
<p>
Time range is overridden by the query `_time` filter.
Remove `_time` from the query to use manual selection.
Disable query time override in Settings.
</p>
</Alert>}
{!error && (
<HitsChart
{...dataLogHits}
Expand Down
Loading