mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
309 lines
9.5 KiB
TypeScript
309 lines
9.5 KiB
TypeScript
// Copyright 2026 OpenObserve Inc.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
// Parses pasted date-range / single date-time text for the DateTime picker.
|
|
// No store/timezone dependency by design: inputs that already resolve to an
|
|
// unambiguous instant (JSON copy payload, epoch timestamp, offset/`Z` ISO)
|
|
// return `"timestamp"` (epoch µs); inputs with no timezone of their own
|
|
// return `"absolute"` (date/time strings), resolved by the caller using the
|
|
// picker's currently selected timezone.
|
|
|
|
export interface AbsoluteRangeResult {
|
|
type: "absolute";
|
|
startDate: string; // YYYY/MM/DD
|
|
startTime: string; // HH:MM:SS
|
|
endDate: string;
|
|
endTime: string;
|
|
}
|
|
|
|
export interface TimestampRangeResult {
|
|
type: "timestamp";
|
|
startMicros: number;
|
|
endMicros: number;
|
|
}
|
|
|
|
export type ParsedDateRange = AbsoluteRangeResult | TimestampRangeResult;
|
|
|
|
export interface SingleAbsoluteResult {
|
|
type: "absolute";
|
|
date: string; // YYYY/MM/DD
|
|
time?: string; // HH:MM:SS — omitted when not given; caller defaults per side
|
|
}
|
|
|
|
export interface SingleTimestampResult {
|
|
type: "timestamp";
|
|
micros: number;
|
|
}
|
|
|
|
export type ParsedSingleDateTime = SingleAbsoluteResult | SingleTimestampResult;
|
|
|
|
const ABSOLUTE_RANGE_RE =
|
|
/^(\d{4}\/\d{2}\/\d{2})(?:\s+(\d{2}:\d{2}:\d{2}))?\s*-\s*(\d{4}\/\d{2}\/\d{2})(?:\s+(\d{2}:\d{2}:\d{2}))?$/;
|
|
const TIMESTAMP_RANGE_RE = /^(\d{10,16})\s*-\s*(\d{10,16})$/;
|
|
const ABSOLUTE_SINGLE_RE = /^(\d{4}\/\d{2}\/\d{2})(?:\s+(\d{2}:\d{2}:\d{2}))?$/;
|
|
const TIMESTAMP_SINGLE_RE = /^(\d{10,16})$/;
|
|
const ISO_SINGLE_RE =
|
|
/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?(Z|[+-]\d{2}(?::?\d{2})?)?$/;
|
|
// The app's own log/trace timestamp display format (src/utils/date.ts formatTimestamp).
|
|
const HUMAN_LOG_SINGLE_RE =
|
|
/^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),?\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(?:\s+(Z|[+-]\d{2}(?::?\d{2})?))?$/i;
|
|
const MONTH_INDEX: Record<string, string> = {
|
|
jan: "01",
|
|
feb: "02",
|
|
mar: "03",
|
|
apr: "04",
|
|
may: "05",
|
|
jun: "06",
|
|
jul: "07",
|
|
aug: "08",
|
|
sep: "09",
|
|
oct: "10",
|
|
nov: "11",
|
|
dec: "12",
|
|
};
|
|
|
|
// date-fns `X` (the app's `Z` token) renders whole-hour zones as `+HH`, with no minutes.
|
|
function normalizeOffset(offset: string): string {
|
|
if (offset.toUpperCase() === "Z") return "Z";
|
|
const [, hours, minutes = "00"] = offset.match(/^([+-]\d{2}):?(\d{2})?$/) ?? [];
|
|
return `${hours}:${minutes}`;
|
|
}
|
|
|
|
function isValidDateTimeParts(
|
|
year: string,
|
|
month: string,
|
|
day: string,
|
|
hour = "00",
|
|
minute = "00",
|
|
second = "00",
|
|
): boolean {
|
|
const yearValue = Number(year);
|
|
const monthValue = Number(month);
|
|
const dayValue = Number(day);
|
|
const hourValue = Number(hour);
|
|
const minuteValue = Number(minute);
|
|
const secondValue = Number(second);
|
|
|
|
if (
|
|
![yearValue, monthValue, dayValue, hourValue, minuteValue, secondValue].every(
|
|
Number.isFinite,
|
|
) ||
|
|
![yearValue, monthValue, dayValue, hourValue, minuteValue].every(Number.isInteger) ||
|
|
monthValue < 1 ||
|
|
monthValue > 12 ||
|
|
dayValue < 1 ||
|
|
hourValue < 0 ||
|
|
hourValue > 23 ||
|
|
minuteValue < 0 ||
|
|
minuteValue > 59 ||
|
|
secondValue < 0 ||
|
|
secondValue >= 60
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const isLeapYear = yearValue % 4 === 0 && (yearValue % 100 !== 0 || yearValue % 400 === 0);
|
|
const daysInMonth = [31, isLeapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][
|
|
monthValue - 1
|
|
];
|
|
|
|
return dayValue <= (daysInMonth ?? 0);
|
|
}
|
|
|
|
function isValidAbsoluteValue(date: string, time?: string): boolean {
|
|
const dateParts = date.split(/[/-]/);
|
|
const timeParts = (time ?? "00:00:00").split(":");
|
|
if (dateParts.length !== 3 || timeParts.length !== 3) return false;
|
|
|
|
return isValidDateTimeParts(
|
|
dateParts[0],
|
|
dateParts[1],
|
|
dateParts[2],
|
|
timeParts[0],
|
|
timeParts[1],
|
|
timeParts[2],
|
|
);
|
|
}
|
|
|
|
export function parseDateRangeString(raw: string): ParsedDateRange | null {
|
|
const s = raw.trim();
|
|
|
|
const json = parseJsonRange(s);
|
|
if (json) return json;
|
|
|
|
const iso = parseRangeVia(s, parseIsoSingle);
|
|
if (iso) return iso;
|
|
|
|
const humanLog = parseRangeVia(s, parseHumanLogSingle);
|
|
if (humanLog) return humanLog;
|
|
|
|
const tsMatch = s.match(TIMESTAMP_RANGE_RE);
|
|
if (tsMatch) {
|
|
return {
|
|
type: "timestamp",
|
|
startMicros: toMicros(tsMatch[1]),
|
|
endMicros: toMicros(tsMatch[2]),
|
|
};
|
|
}
|
|
|
|
const absMatch = s.match(ABSOLUTE_RANGE_RE);
|
|
if (absMatch) {
|
|
if (!isValidAbsoluteValue(absMatch[1], absMatch[2])) return null;
|
|
if (!isValidAbsoluteValue(absMatch[3], absMatch[4])) return null;
|
|
return {
|
|
type: "absolute",
|
|
startDate: absMatch[1],
|
|
startTime: absMatch[2] ?? "00:00:00",
|
|
endDate: absMatch[3],
|
|
endTime: absMatch[4] ?? "23:59:59",
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function parseSingleDateTime(raw: string): ParsedSingleDateTime | null {
|
|
const s = raw.trim();
|
|
|
|
const iso = parseIsoSingle(s) ?? parseHumanLogSingle(s);
|
|
if (iso) {
|
|
return iso.hasOffset
|
|
? { type: "timestamp", micros: iso.micros as number }
|
|
: { type: "absolute", date: iso.date as string, time: iso.time };
|
|
}
|
|
|
|
const tsMatch = s.match(TIMESTAMP_SINGLE_RE);
|
|
if (tsMatch) {
|
|
return { type: "timestamp", micros: toMicros(tsMatch[1]) };
|
|
}
|
|
|
|
const absMatch = s.match(ABSOLUTE_SINGLE_RE);
|
|
if (absMatch) {
|
|
const [date, time] = [absMatch[1], absMatch[2]];
|
|
if (!isValidAbsoluteValue(date, time)) return null;
|
|
return { type: "absolute", date: absMatch[1], time: absMatch[2] };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
interface IsoSingleParsed {
|
|
hasOffset: boolean;
|
|
micros?: number; // set when hasOffset
|
|
date?: string; // YYYY/MM/DD — set when !hasOffset
|
|
time?: string; // HH:MM:SS — set when !hasOffset and a time was given
|
|
}
|
|
|
|
function parseIsoSingle(raw: string): IsoSingleParsed | null {
|
|
const m = raw.match(ISO_SINGLE_RE);
|
|
if (!m) return null;
|
|
const [, year, month, day, hour, minute, second, offset] = m;
|
|
|
|
if (!isValidDateTimeParts(year, month, day, hour, minute, second)) return null;
|
|
|
|
if (offset) {
|
|
// Explicit offset already IS the instant — ignores the picker's timezone.
|
|
const normalizedOffset = normalizeOffset(offset);
|
|
const iso = `${year}-${month}-${day}T${hour ?? "00"}:${minute ?? "00"}:${second ?? "00"}${normalizedOffset}`;
|
|
const ms = Date.parse(iso);
|
|
if (Number.isNaN(ms)) return null;
|
|
return { hasOffset: true, micros: ms * 1000 };
|
|
}
|
|
|
|
return {
|
|
hasOffset: false,
|
|
date: `${year}/${month}/${day}`,
|
|
time: hour ? `${hour}:${minute}:${second ?? "00"}` : undefined,
|
|
};
|
|
}
|
|
|
|
function parseHumanLogSingle(raw: string): IsoSingleParsed | null {
|
|
const m = raw.match(HUMAN_LOG_SINGLE_RE);
|
|
if (!m) return null;
|
|
const [, monthAbbr, day, year, hour, minute, second, ms, offset] = m;
|
|
const month = MONTH_INDEX[monthAbbr.toLowerCase()];
|
|
const dd = day.padStart(2, "0");
|
|
const secWithMs = ms ? `${second}.${ms}` : second;
|
|
|
|
if (!isValidDateTimeParts(year, month, dd, hour, minute, secWithMs)) return null;
|
|
|
|
if (offset) {
|
|
const normalizedOffset = normalizeOffset(offset);
|
|
const iso = `${year}-${month}-${dd}T${hour}:${minute}:${secWithMs}${normalizedOffset}`;
|
|
const parsedMs = Date.parse(iso);
|
|
if (Number.isNaN(parsedMs)) return null;
|
|
return { hasOffset: true, micros: parsedMs * 1000 };
|
|
}
|
|
|
|
return {
|
|
hasOffset: false,
|
|
date: `${year}/${month}/${dd}`,
|
|
time: `${hour}:${minute}:${secWithMs}`,
|
|
};
|
|
}
|
|
|
|
// Splits on a whitespace-padded ` - ` — safe for ISO/human-log dates, which
|
|
// contain a bare `-` but never whitespace around it.
|
|
function parseRangeVia(
|
|
s: string,
|
|
parseSingle: (part: string) => IsoSingleParsed | null,
|
|
): ParsedDateRange | null {
|
|
const parts = s.split(/\s+-\s+/);
|
|
if (parts.length !== 2) return null;
|
|
|
|
const start = parseSingle(parts[0]);
|
|
const end = parseSingle(parts[1]);
|
|
// Both sides must agree on having/not having an offset, else they'd be
|
|
// interpreted in different frames of reference.
|
|
if (!start || !end || start.hasOffset !== end.hasOffset) return null;
|
|
|
|
if (start.hasOffset) {
|
|
return {
|
|
type: "timestamp",
|
|
startMicros: start.micros as number,
|
|
endMicros: end.micros as number,
|
|
};
|
|
}
|
|
return {
|
|
type: "absolute",
|
|
startDate: start.date as string,
|
|
startTime: start.time ?? "00:00:00",
|
|
endDate: end.date as string,
|
|
endTime: end.time ?? "23:59:59",
|
|
};
|
|
}
|
|
|
|
// The `{"start_date":<µs>,"end_date":<µs>}` payload Copy puts on the clipboard.
|
|
function parseJsonRange(s: string): TimestampRangeResult | null {
|
|
if (!s.startsWith("{")) return null;
|
|
try {
|
|
const obj = JSON.parse(s);
|
|
if (typeof obj?.start_date === "number" && typeof obj?.end_date === "number") {
|
|
return { type: "timestamp", startMicros: obj.start_date, endMicros: obj.end_date };
|
|
}
|
|
} catch {
|
|
// not JSON
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Auto-detect epoch unit by digit length; mirrors DateTime.vue's defaultAbsoluteTime.
|
|
function toMicros(digits: string): number {
|
|
const n = Number(digits);
|
|
if (digits.length <= 10) return n * 1_000_000; // seconds
|
|
if (digits.length <= 13) return n * 1_000; // milliseconds
|
|
return n; // microseconds
|
|
}
|