Files
openobserve/web/src/plugins/correlation/TimeRangeEditor.vue
T
5c56d05703 fix: layout ux3 (#12560)
Co-authored-by: Kirtan Thakkar <kirtan@kiara.tech>
Co-authored-by: ktx-mihir <mihir.patel@kiara.tech>
Co-authored-by: ktx-riya <riya.kukadiya@kiara.tech>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-10 14:17:21 +00:00

491 lines
14 KiB
Vue

<!-- 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/>.
-->
<template>
<ODialog
data-test="time-range-editor-dialog"
:open="modelValue"
@update:open="
(v) => {
$emit('update:modelValue', v);
if (!v) handleClose();
}
"
size="sm"
:title="t('correlation.logs.timeRange.title')"
:secondary-button-label="t('common.cancel')"
:neutral-button-label="t('common.reset')"
:primary-button-label="t('common.apply')"
:primary-button-disabled="!isValid"
@click:secondary="handleCancel"
@click:neutral="handleReset"
@click:primary="handleApply"
>
<!-- Source Log Time -->
<div class="tw:mb-6">
<div class="tw:text-sm tw:font-semibold tw:mb-2">
{{ t("correlation.logs.timeRange.sourceTime") }}
</div>
<div
class="tw:p-3 tw:border tw:border-solid tw:border-[var(--o2-border-color)] tw:rounded tw:bg-surface-panel tw:flex tw:items-center tw:gap-2"
>
<OIcon name="schedule" size="sm" />
<span class="tw:font-mono tw:text-sm">{{
formatTimestamp(sourceTimestamp)
}}</span>
</div>
</div>
<!-- Time Window Presets -->
<div class="tw:mb-6">
<div class="tw:text-sm tw:font-semibold tw:mb-3">
{{ t("correlation.logs.timeRange.window") }}
</div>
<div class="tw:space-y-2">
<ORadioGroup
:model-value="selectedWindow"
orientation="vertical"
@update:model-value="onWindowSelect"
>
<ORadio
val="1min"
:label="t('correlation.logs.timeRange.minute1')"
data-test="window-1min"
/>
<ORadio
val="5min"
:label="t('correlation.logs.timeRange.minute5')"
data-test="window-5min"
/>
<ORadio
val="15min"
:label="t('correlation.logs.timeRange.minute15')"
data-test="window-15min"
/>
<ORadio
val="30min"
:label="t('correlation.logs.timeRange.minute30')"
data-test="window-30min"
/>
<ORadio
val="1hour"
:label="t('correlation.logs.timeRange.hour1')"
data-test="window-1hour"
/>
<ORadio
val="custom"
:label="t('correlation.logs.timeRange.custom')"
data-test="window-custom"
/>
</ORadioGroup>
</div>
</div>
<!-- Custom Time Range Inputs (only shown when custom is selected) -->
<div v-if="selectedWindow === 'custom'" class="tw:mb-4 tw:space-y-3">
<div>
<div class="tw:text-sm tw:font-semibold tw:mb-2">
{{ t("correlation.logs.timeRange.customStart") }}
</div>
<OInput
v-model="customStartTime"
type="datetime-local"
:error="!!startTimeError"
:error-message="startTimeError"
@update:model-value="onStartTimeChange"
data-test="custom-start-input"
/>
</div>
<div>
<div class="tw:text-sm tw:font-semibold tw:mb-2">
{{ t("correlation.logs.timeRange.customEnd") }}
</div>
<OInput
v-model="customEndTime"
type="datetime-local"
:error="!!endTimeError"
:error-message="endTimeError"
@update:model-value="onEndTimeChange"
data-test="custom-end-input"
/>
</div>
</div>
<!-- Current Range Display -->
<div
class="tw:p-3 tw:border tw:border-solid tw:border-[var(--o2-border-color)] tw:rounded tw:bg-blue-50"
>
<div class="tw:text-xs tw:font-semibold tw:mb-2 tw:opacity-70">
{{ t("correlation.logs.timeRange.currentRange") }}
</div>
<div class="tw:flex tw:flex-col tw:gap-1 tw:text-sm">
<div class="tw:flex tw:items-center tw:gap-2">
<span class="tw:font-semibold"
>{{ t("correlation.logs.timeRange.start") }}:</span
>
<span class="tw:font-mono">{{
formatTimestamp(pendingStartTime)
}}</span>
</div>
<div class="tw:flex tw:items-center tw:gap-2">
<span class="tw:font-semibold"
>{{ t("correlation.logs.timeRange.end") }}:</span
>
<span class="tw:font-mono">{{
formatTimestamp(pendingEndTime)
}}</span>
</div>
<div class="tw:flex tw:items-center tw:gap-2 tw:mt-1">
<span class="tw:font-semibold"
>{{ t("correlation.logs.timeRange.duration") }}:</span
>
<span>{{ formatDuration(pendingEndTime - pendingStartTime) }}</span>
</div>
</div>
</div>
</ODialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useI18n } from "vue-i18n";
import { formatDate } from "@/utils/date";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import ORadio from "@/lib/forms/Radio/ORadio.vue";
import ORadioGroup from "@/lib/forms/Radio/ORadioGroup.vue";
interface Props {
modelValue: boolean;
currentRange: { startTime: number; endTime: number };
sourceTimestamp: number;
}
// Props & Emits
const props = defineProps<Props>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
"update:range": [startTime: number, endTime: number];
close: [];
}>();
// Composables
const { t } = useI18n();
// State
const selectedWindow = ref<string>("5min");
function onWindowSelect(val: string) {
selectedWindow.value = val;
if (val !== "custom") applyPreset();
}
const pendingStartTime = ref<number>(props.currentRange.startTime);
const pendingEndTime = ref<number>(props.currentRange.endTime);
const customStartTime = ref<string>("");
const customEndTime = ref<string>("");
const startTimeError = ref("");
const endTimeError = ref("");
// Computed
const isValid = computed(() => {
if (selectedWindow.value === "custom") {
return (
customStartTime.value &&
customEndTime.value &&
pendingStartTime.value < pendingEndTime.value
);
}
return pendingStartTime.value < pendingEndTime.value;
});
/**
* Format timestamp (microseconds) to human-readable format
*/
const formatTimestamp = (timestamp: number): string => {
const ms = Math.floor(timestamp / 1000);
return formatDate(ms, "YYYY-MM-DD HH:mm:ss.SSS");
};
/**
* Format duration (microseconds) to human-readable format
*/
const formatDuration = (durationMicros: number): string => {
const durationMs = durationMicros / 1000;
const durationSeconds = durationMs / 1000;
if (durationSeconds < 60) {
return `${Math.round(durationSeconds)} seconds`;
} else if (durationSeconds < 3600) {
const minutes = Math.floor(durationSeconds / 60);
const seconds = Math.round(durationSeconds % 60);
return seconds > 0 ? `${minutes} min ${seconds} sec` : `${minutes} minutes`;
} else {
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
return minutes > 0 ? `${hours} hour ${minutes} min` : `${hours} hours`;
}
};
/**
* Apply preset time window
*/
const applyPreset = () => {
let windowMicros: number;
switch (selectedWindow.value) {
case "1min":
windowMicros = 1 * 60 * 1000 * 1000; // 1 minute in microseconds
break;
case "5min":
windowMicros = 5 * 60 * 1000 * 1000; // 5 minutes
break;
case "15min":
windowMicros = 15 * 60 * 1000 * 1000; // 15 minutes
break;
case "30min":
windowMicros = 30 * 60 * 1000 * 1000; // 30 minutes
break;
case "1hour":
windowMicros = 60 * 60 * 1000 * 1000; // 1 hour
break;
default:
return; // Custom - don't apply preset
}
// Calculate ±window around source timestamp
pendingStartTime.value = props.sourceTimestamp - windowMicros;
pendingEndTime.value = props.sourceTimestamp + windowMicros;
};
/**
* Convert datetime-local input to microsecond timestamp
*/
const datetimeToMicros = (datetime: string): number => {
return new Date(datetime).getTime() * 1000; // Convert ms to microseconds
};
/**
* Convert microsecond timestamp to datetime-local format
*/
const microsToDatetime = (micros: number): string => {
const ms = Math.floor(micros / 1000);
return formatDate(ms, "YYYY-MM-DDTHH:mm");
};
const onStartTimeChange = (val: string) => {
const result = validateStartTime(val);
startTimeError.value = result === true ? "" : (result as string);
};
const onEndTimeChange = (val: string) => {
const result = validateEndTime(val);
endTimeError.value = result === true ? "" : (result as string);
};
/**
* Validate start time
*/
const validateStartTime = (val: string): boolean | string => {
if (!val) return t("validation.required");
const startMicros = datetimeToMicros(val);
pendingStartTime.value = startMicros;
if (startMicros >= pendingEndTime.value) {
return t("correlation.logs.timeRange.startBeforeEnd");
}
return true;
};
/**
* Validate end time
*/
const validateEndTime = (val: string): boolean | string => {
if (!val) return t("validation.required");
const endMicros = datetimeToMicros(val);
pendingEndTime.value = endMicros;
if (endMicros <= pendingStartTime.value) {
return t("correlation.logs.timeRange.endAfterStart");
}
return true;
};
/**
* Detect current window from time range
*/
const detectCurrentWindow = () => {
const duration = pendingEndTime.value - pendingStartTime.value;
const center = (pendingStartTime.value + pendingEndTime.value) / 2;
// Check if range is symmetric around source timestamp (within 1 second tolerance)
const isSymmetric = Math.abs(center - props.sourceTimestamp) < 1000000; // 1 second in microseconds
if (!isSymmetric) {
selectedWindow.value = "custom";
return;
}
// Detect preset based on duration
const durationMinutes = duration / (60 * 1000 * 1000);
if (Math.abs(durationMinutes - 2) < 0.1) {
// ±1 min = 2 min total
selectedWindow.value = "1min";
} else if (Math.abs(durationMinutes - 10) < 0.1) {
// ±5 min = 10 min total
selectedWindow.value = "5min";
} else if (Math.abs(durationMinutes - 30) < 0.1) {
// ±15 min = 30 min total
selectedWindow.value = "15min";
} else if (Math.abs(durationMinutes - 60) < 0.1) {
// ±30 min = 60 min total
selectedWindow.value = "30min";
} else if (Math.abs(durationMinutes - 120) < 0.1) {
// ±1 hour = 120 min total
selectedWindow.value = "1hour";
} else {
selectedWindow.value = "custom";
}
};
/**
* Handle apply button click
*/
const handleApply = () => {
if (selectedWindow.value === "custom") {
// Use custom times
const startMicros = datetimeToMicros(customStartTime.value);
const endMicros = datetimeToMicros(customEndTime.value);
if (startMicros < endMicros) {
emit("update:range", startMicros, endMicros);
emit("update:modelValue", false);
}
} else {
// Use preset times
emit("update:range", pendingStartTime.value, pendingEndTime.value);
emit("update:modelValue", false);
}
};
/**
* Handle cancel button click
*/
const handleCancel = () => {
// Restore original range
pendingStartTime.value = props.currentRange.startTime;
pendingEndTime.value = props.currentRange.endTime;
detectCurrentWindow();
emit("update:modelValue", false);
emit("close");
};
/**
* Handle reset button click
*/
const handleReset = () => {
// Reset to ±5 minutes
selectedWindow.value = "5min";
applyPreset();
};
/**
* Handle dialog close
*/
const handleClose = () => {
// Restore original range
pendingStartTime.value = props.currentRange.startTime;
pendingEndTime.value = props.currentRange.endTime;
detectCurrentWindow();
emit("close");
};
// Watch for prop changes
watch(
() => props.currentRange,
(newRange) => {
pendingStartTime.value = newRange.startTime;
pendingEndTime.value = newRange.endTime;
detectCurrentWindow();
},
{ deep: true },
);
watch(
() => props.modelValue,
(isOpen) => {
if (isOpen) {
// Reset pending times when dialog opens
pendingStartTime.value = props.currentRange.startTime;
pendingEndTime.value = props.currentRange.endTime;
detectCurrentWindow();
// Initialize custom datetime inputs
customStartTime.value = microsToDatetime(pendingStartTime.value);
customEndTime.value = microsToDatetime(pendingEndTime.value);
}
},
);
// Watch custom inputs and update pending times
watch(customStartTime, (newVal) => {
if (newVal) {
pendingStartTime.value = datetimeToMicros(newVal);
}
});
watch(customEndTime, (newVal) => {
if (newVal) {
pendingEndTime.value = datetimeToMicros(newVal);
}
});
// Initialize on mount
detectCurrentWindow();
</script>
<style lang="scss" scoped>
.tw\\:bg-gray-50 {
background-color: rgba(0, 0, 0, 0.02);
}
.tw\\:bg-blue-50 {
background-color: rgba(33, 150, 243, 0.05);
}
// Dark theme adjustments
:deep(.q-dark) {
.tw\\:bg-gray-50 {
background-color: rgba(255, 255, 255, 0.03);
}
.tw\\:bg-blue-50 {
background-color: rgba(33, 150, 243, 0.1);
}
.tw\\:text-gray-600 {
color: rgba(255, 255, 255, 0.7);
}
}
</style>