{{
@@ -73,7 +73,7 @@
- New
+ New
@@ -89,7 +89,7 @@
- {{ isEditorVisible ? "Hide" : "Show" }} SQL
+ {{ isEditorVisible ? "Hide" : "Show" }} SQL
@@ -608,7 +608,6 @@ import {
PanelRightOpen,
PanelRightClose,
AlertCircle,
- FileEdit,
FilePlus2,
Search,
Code2,
@@ -1954,8 +1953,7 @@ const route = useRoute();
const router = useRouter();
// Add these computed properties after other computed properties
-const activeSavedQueryName = computed(() => exploreStore.activeSavedQueryName);
-const isEditingExistingQuery = computed(() => !!route.query.query_id);
+const isEditingExistingQuery = computed(() => !!route.query.id);
// Handler for loading query from history
const handleLoadQueryFromHistory = (mode: 'logchefql' | 'sql', query: string) => {
@@ -1986,8 +1984,8 @@ const handleNewQueryClick = () => {
// First, copy the current query parameters
const currentQuery = { ...route.query };
- // Remove query_id from URL
- delete currentQuery.query_id;
+ // Remove saved query id from URL
+ delete currentQuery.id;
// Use the centralized reset function in the store
exploreStore.resetQueryToDefaults();
@@ -2000,12 +1998,11 @@ const handleNewQueryClick = () => {
// Wait for the state reset to complete
nextTick(() => {
- // Explicitly remove the query_id parameter again to ensure it's gone
+ // Explicitly remove the id parameter again to ensure it's gone
const finalQuery = { ...currentQuery };
- delete finalQuery.query_id;
+ delete finalQuery.id;
- // Replace URL with new parameters in router, disabling any redirect
- // This is important to prevent the URL sync from adding back query_id
+ // Replace URL with new parameters in router
router
.replace({ query: finalQuery })
.then(() => {
diff --git a/frontend/src/composables/useConnectionValidation.ts b/frontend/src/composables/useConnectionValidation.ts
deleted file mode 100644
index 566a7ba14..000000000
--- a/frontend/src/composables/useConnectionValidation.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { ref, computed } from 'vue'
-import { useToast } from '@/composables/useToast'
-import { TOAST_DURATION } from '@/lib/constants'
-import { useSourcesStore } from '@/stores/sources'
-
-export interface ConnectionInfo {
- host: string;
- username: string;
- password: string;
- database: string;
- table_name: string;
- timestamp_field?: string;
- severity_field?: string;
-}
-
-export interface ValidationResult {
- success: boolean;
- message: string;
-}
-
-export function useConnectionValidation() {
- const sourcesStore = useSourcesStore()
- const { toast } = useToast()
-
- const isValidating = ref(false)
- const validationResult = ref(null)
-
- // Compute validation status from store
- const isValidated = computed(() => {
- const connection = currentConnection.value
- if (!connection?.host || !connection?.database || !connection?.table_name) return false
-
- return sourcesStore.isConnectionValidated(
- connection.host,
- connection.database,
- connection.table_name
- )
- })
-
- // Track current connection info
- const currentConnection = ref(null)
-
- const validateConnection = async (connectionInfo: ConnectionInfo) => {
- if (isValidating.value) return { success: false }
-
- // Update current connection
- currentConnection.value = { ...connectionInfo }
-
- if (!connectionInfo.host || !connectionInfo.database || !connectionInfo.table_name) {
- toast({
- title: 'Error',
- description: 'Please fill in host, database and table name fields',
- variant: 'destructive',
- duration: TOAST_DURATION.ERROR,
- })
- return { success: false }
- }
-
- isValidating.value = true
- validationResult.value = null
-
- try {
- const result = await sourcesStore.validateSourceConnection({
- host: connectionInfo.host,
- username: connectionInfo.username || '',
- password: connectionInfo.password || '',
- database: connectionInfo.database,
- table_name: connectionInfo.table_name,
- timestamp_field: connectionInfo.timestamp_field,
- severity_field: connectionInfo.severity_field,
- })
-
- if (result.success) {
- validationResult.value = {
- success: true,
- message: result.data?.message || 'Connection validated successfully'
- }
-
- return { success: true, data: result.data }
- } else {
- validationResult.value = {
- success: false,
- message: result.error || 'Validation failed'
- }
-
- return { success: false, error: result.error }
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'
- validationResult.value = {
- success: false,
- message: errorMessage
- }
-
- return { success: false, error: errorMessage }
- } finally {
- isValidating.value = false
- }
- }
-
- return {
- isValidating,
- validationResult,
- isValidated,
- currentConnection,
- validateConnection,
- }
-}
diff --git a/frontend/src/composables/useContextSync.ts b/frontend/src/composables/useContextSync.ts
new file mode 100644
index 000000000..a8540d87a
--- /dev/null
+++ b/frontend/src/composables/useContextSync.ts
@@ -0,0 +1,211 @@
+import { ref, computed, watch, type Ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useContextStore } from '@/stores/context';
+import { useTeamsStore } from '@/stores/teams';
+import { useSourcesStore } from '@/stores/sources';
+
+export type ContextSyncState = 'idle' | 'loading' | 'ready' | 'error';
+
+interface UseContextSyncOptions {
+ syncUrl?: boolean;
+ basePath?: string;
+}
+
+interface UseContextSyncReturn {
+ state: Ref;
+ error: Ref;
+ isReady: Ref;
+ isLoading: Ref;
+ teamId: Ref;
+ sourceId: Ref;
+ initialize: () => Promise;
+ handleTeamChange: (teamId: number) => Promise;
+ handleSourceChange: (sourceId: number) => Promise;
+}
+
+export function useContextSync(options: UseContextSyncOptions = {}): UseContextSyncReturn {
+ const { syncUrl = true, basePath } = options;
+
+ const route = useRoute();
+ const router = useRouter();
+ const contextStore = useContextStore();
+ const teamsStore = useTeamsStore();
+ const sourcesStore = useSourcesStore();
+
+ const state = ref('idle');
+ const error = ref(null);
+ const isReady = computed(() => state.value === 'ready');
+ const isLoading = computed(() => state.value === 'loading');
+
+ const teamId = computed(() => contextStore.teamId);
+ const sourceId = computed(() => contextStore.sourceId);
+
+ function parseId(value: unknown): number | null {
+ if (value == null) return null;
+ const parsed = parseInt(String(value), 10);
+ return Number.isNaN(parsed) ? null : parsed;
+ }
+
+ async function waitForSourcesLoaded(timeoutMs = 5000): Promise {
+ if (!sourcesStore.isLoadingTeamSources && contextStore.sourceId) {
+ return;
+ }
+
+ return new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ stopWatch();
+ resolve();
+ }, timeoutMs);
+
+ const stopWatch = watch(
+ () => [sourcesStore.isLoadingTeamSources, contextStore.sourceId] as const,
+ ([loading, srcId]) => {
+ if (!loading && srcId) {
+ clearTimeout(timeout);
+ stopWatch();
+ resolve();
+ }
+ },
+ { immediate: true }
+ );
+ });
+ }
+
+ async function initialize(): Promise {
+ if (state.value === 'loading') return;
+
+ state.value = 'loading';
+ error.value = null;
+
+ try {
+ if (!teamsStore.userTeams || teamsStore.userTeams.length === 0) {
+ await teamsStore.loadUserTeams();
+ }
+
+ if (teamsStore.teams.length === 0) {
+ error.value = 'No teams available.';
+ state.value = 'error';
+ return;
+ }
+
+ const urlTeam = parseId(route.query.team);
+ const urlSource = parseId(route.query.source);
+ const storedDefaults = contextStore.getStoredDefaults();
+
+ let targetTeamId = urlTeam;
+ if (!targetTeamId || !teamsStore.teams.some(t => t.id === targetTeamId)) {
+ targetTeamId = storedDefaults.teamId;
+ }
+ if (!targetTeamId || !teamsStore.teams.some(t => t.id === targetTeamId)) {
+ targetTeamId = teamsStore.teams[0]?.id ?? null;
+ }
+
+ if (!targetTeamId) {
+ error.value = 'No team available.';
+ state.value = 'error';
+ return;
+ }
+
+ contextStore.setFromRoute(targetTeamId, urlSource);
+
+ await waitForSourcesLoaded();
+
+ if (syncUrl) {
+ await syncUrlToContext();
+ }
+
+ state.value = 'ready';
+
+ } catch (err: any) {
+ console.error('useContextSync: Initialization error:', err);
+ error.value = err.message || 'Failed to initialize context.';
+ state.value = 'error';
+ }
+ }
+
+ async function syncUrlToContext(): Promise {
+ const query: Record = {};
+
+ if (contextStore.teamId) {
+ query.team = String(contextStore.teamId);
+ }
+ if (contextStore.sourceId) {
+ query.source = String(contextStore.sourceId);
+ }
+
+ const currentTeam = route.query.team;
+ const currentSource = route.query.source;
+
+ const needsUpdate =
+ (query.team && currentTeam !== query.team) ||
+ (query.source && currentSource !== query.source) ||
+ (!query.source && currentSource);
+
+ if (needsUpdate) {
+ await router.replace({ path: basePath ?? route.path, query });
+ }
+ }
+
+ async function handleTeamChange(newTeamId: number): Promise {
+ if (newTeamId === contextStore.teamId) return;
+
+ contextStore.selectTeam(newTeamId);
+
+ await waitForSourcesLoaded();
+
+ if (syncUrl) {
+ await syncUrlToContext();
+ }
+ }
+
+ async function handleSourceChange(newSourceId: number): Promise {
+ if (newSourceId === contextStore.sourceId) return;
+ if (!contextStore.teamId) return;
+
+ if (!sourcesStore.teamSources.some(s => s.id === newSourceId)) {
+ console.warn(`useContextSync: Source ${newSourceId} not found`);
+ return;
+ }
+
+ contextStore.selectSource(newSourceId);
+
+ if (syncUrl) {
+ await syncUrlToContext();
+ }
+ }
+
+ watch(
+ () => [route.query.team, route.query.source] as const,
+ async ([urlTeam, urlSource], [prevTeam, prevSource]) => {
+ if (state.value !== 'ready') return;
+
+ const newTeam = parseId(urlTeam);
+ const newSource = parseId(urlSource);
+ const prevTeamId = parseId(prevTeam);
+ const prevSourceId = parseId(prevSource);
+
+ if (newTeam === prevTeamId && newSource === prevSourceId) return;
+
+ if (newTeam && newTeam !== contextStore.teamId) {
+ await handleTeamChange(newTeam);
+ if (newSource && newSource !== contextStore.sourceId) {
+ await handleSourceChange(newSource);
+ }
+ } else if (newSource && newSource !== contextStore.sourceId) {
+ await handleSourceChange(newSource);
+ }
+ }
+ );
+
+ return {
+ state,
+ error,
+ isReady,
+ isLoading,
+ teamId,
+ sourceId,
+ initialize,
+ handleTeamChange,
+ handleSourceChange,
+ };
+}
diff --git a/frontend/src/composables/useExploreUrlSync.ts b/frontend/src/composables/useExploreUrlSync.ts
deleted file mode 100644
index dcfab6fdc..000000000
--- a/frontend/src/composables/useExploreUrlSync.ts
+++ /dev/null
@@ -1,260 +0,0 @@
-import { ref, watch, nextTick } from 'vue';
-import { useRoute, useRouter } from 'vue-router';
-import { useExploreStore } from '@/stores/explore';
-import { useTeamsStore } from '@/stores/teams';
-import { useSourcesStore } from '@/stores/sources';
-// Removed complex coordination imports - using clean router-first approach now
-
-export function useExploreUrlSync() {
- const route = useRoute();
- const router = useRouter();
- const exploreStore = useExploreStore();
- const teamsStore = useTeamsStore();
- const sourcesStore = useSourcesStore();
-
- const isInitializing = ref(true);
- const initializationError = ref(null);
- const skipNextUrlSync = ref(false);
-
- // A guard flag to prevent URL updates during the active loading of a page with relativeTime
- let preservingRelativeTime = false;
-
- // Add a last initialization timestamp to prevent rapid re-initialization
- let lastInitTimestamp = 0;
-
- // Add a debounce timer to avoid syncing during typing
- let syncDebounceTimer: number | null = null;
-
- // --- Initialization Logic ---
-
- async function initializeFromUrl() {
- // Prevent multiple initializations within 500ms of each other
- const now = Date.now();
- if (now - lastInitTimestamp < 500) {
- console.log("useExploreUrlSync: Skipping initialization - too soon after previous init");
- return;
- }
- lastInitTimestamp = now;
-
- isInitializing.value = true;
- initializationError.value = null;
-
- // Check immediately if we have a relative time parameter in the URL
- const hasRelativeTimeParam = !!route.query.relativeTime;
- if (hasRelativeTimeParam) {
- // Set a flag to make all URL sync operations more cautious
- preservingRelativeTime = true;
- }
-
- try {
- // 1. Ensure Teams are loaded (wait if necessary)
- if (!teamsStore.teams || teamsStore.teams.length === 0) {
- await teamsStore.loadTeams(false, false); // Explicitly use user teams endpoint
- }
-
- // Handle the case where no teams are available more gracefully
- if (teamsStore.teams.length === 0) {
- // Set initialization error without throwing
- initializationError.value = "No teams available or accessible.";
-
- // Clear any existing data for consistency
- exploreStore.setSource(0);
- sourcesStore.clearCurrentSourceDetails();
-
- // Mark initialization as complete even with error
- isInitializing.value = false;
-
- // Exit early but don't throw - allow the component to handle this state
- return;
- }
-
- // For explore routes without team/source params, ensure proper initialization
- if (route.path.startsWith('/logs/') && Object.keys(route.query).length === 0) {
- console.log('useExploreUrlSync: Initializing explore route with no URL params - teams are available');
- // Teams are loaded, router guard should have set a default team
- // Just ensure we complete initialization quickly
- }
-
- // Team/source initialization is now handled by router guard
- // Just initialize query params from URL
- exploreStore.initializeFromUrl(route.query as Record);
-
- } catch (error: any) {
- console.error("useExploreUrlSync: Error during initialization:", error);
- initializationError.value = error.message || "Failed to initialize from URL.";
- } finally {
- // Use nextTick to ensure all initial store updates have propagated
- // before allowing watchers to update the URL.
- await nextTick();
-
- // Mark initialization as complete *after* the next tick
- isInitializing.value = false;
-
- // Determine URL sync behavior *after* initialization is marked complete
- // Never auto-sync URL during load if we're preserving a relative time URL
- if (!preservingRelativeTime) {
- // Normal URL sync if no special handling is needed
- console.log('Safe to auto-sync URL now');
- } else {
- // We're preserving a relative time parameter - special URL sync behavior
- console.log('Preserving relative time parameter in URL - no auto sync');
-
- // Keep the preservation mode active for a bit longer
- setTimeout(() => {
- preservingRelativeTime = false;
- console.log('Relative time preservation mode deactivated');
- }, 2000); // Ensure initial query completes first
- }
- }
- }
-
- // --- URL Update Logic ---
-
- const syncUrlFromState = () => {
- // Don't sync if we are still initializing from the URL
- if (isInitializing.value) {
- return;
- }
-
- // Note: Team/source coordination is now handled by router guard, not here
-
- // Skip this URL sync if the flag is set
- if (skipNextUrlSync.value) {
- console.log("Skipping URL sync as requested - waiting for pushQueryHistoryEntry");
- skipNextUrlSync.value = false; // Reset the flag
- return;
- }
-
- // If we're in preservation mode and URL already has relativeTime, don't change it
- if (preservingRelativeTime && route.query.relativeTime) {
- console.log(`Protecting relativeTime=${route.query.relativeTime} from URL sync`);
- return;
- }
-
- // Validate that current source belongs to current team before syncing
- if (exploreStore.sourceId && teamsStore.currentTeamId) {
- const currentTeamSources = sourcesStore.teamSources || [];
- const sourceExists = currentTeamSources.some(s => s.id === exploreStore.sourceId);
- if (!sourceExists) {
- console.log(`Skipping URL sync - source ${exploreStore.sourceId} doesn't belong to team ${teamsStore.currentTeamId}`);
- return;
- }
- }
-
- // Use the store's urlQueryParameters computed property
- const query = exploreStore.urlQueryParameters;
-
- // DO NOT try to handle encoding here - let Vue Router handle it
- // The URL framework will automatically encode values as needed
-
- // Compare with current URL and update only if changed
- if (JSON.stringify(query) !== JSON.stringify(route.query)) {
- console.log("URL Sync: Updating URL parameters:", JSON.stringify(query));
- router.replace({ query }).catch(err => {
- // Ignore navigation duplicated errors which can happen with rapid updates
- if (err.name !== 'NavigationDuplicated') {
- console.error("useExploreUrlSync: Error updating URL:", err);
- }
- });
- }
- };
-
- // Push a history entry when a query is executed
- const pushQueryHistoryEntry = () => {
- // Don't push if we are still initializing from the URL
- if (isInitializing.value) {
- return;
- }
-
- // Set the flag to skip the next automatic URL sync
- skipNextUrlSync.value = true;
-
- // Use the store's urlQueryParameters computed property
- const query = exploreStore.urlQueryParameters;
-
- // DO NOT try to handle encoding manually - let Vue Router handle it
- // The URL framework will automatically encode values as needed
-
- console.log("Push History: Using parameters:", JSON.stringify(query));
-
- // Use router.push instead of router.replace to create a new history entry
- router.push({ query }).catch(err => {
- // Ignore navigation duplicated errors
- if (err.name !== 'NavigationDuplicated') {
- console.error("useExploreUrlSync: Error pushing query history:", err);
- }
- });
- };
-
- // --- Watchers ---
-
- // Modify the watch function to prevent immediate syncing of query content
- watch(
- [
- // Watch relevant state properties through the store
- () => teamsStore.currentTeamId,
- () => exploreStore.sourceId,
- () => exploreStore.limit,
- () => exploreStore.timeRange,
- () => exploreStore.selectedRelativeTime,
- () => exploreStore.activeMode,
- // Don't trigger URL updates during typing for these values
- // We'll handle them separately with manual sync
- // () => exploreStore.logchefqlCode,
- // () => exploreStore.rawSql,
- ],
- () => {
- // Avoid syncing during the initial setup phase
- if (isInitializing.value) {
- return;
- }
- // Don't sync if we're planning to push a history entry instead
- if (skipNextUrlSync.value) {
- return;
- }
- syncUrlFromState();
- },
- { deep: true } // Use deep watch for objects like timeRange
- );
-
- // Watch route changes to re-initialize if necessary (e.g., browser back/forward)
- // Note: This might be too aggressive if other query params change often.
- // Consider making this more specific if needed.
- watch(() => route?.fullPath, (newPath, oldPath) => {
- // Skip if route is undefined
- if (!route) return;
-
- // Only re-initialize if the path itself or the core query params changed significantly
- // Avoid re-init on minor changes if updateUrlFromState handles them.
- // A simple check for now: re-init if path changes.
- if (newPath !== oldPath && !isInitializing.value) {
- // Re-run initialization logic when route changes
- // initializeFromUrl(); // Potentially re-enable if back/forward needs full re-init
- }
- });
-
- // Add a new function to manually sync URL after typing completes
- function debouncedSyncUrlFromState() {
- // Cancel any pending timers
- if (syncDebounceTimer !== null) {
- clearTimeout(syncDebounceTimer);
- }
-
- // Set a new timer to sync after a delay
- syncDebounceTimer = window.setTimeout(() => {
- if (!isInitializing.value && !skipNextUrlSync.value) {
- syncUrlFromState();
- }
- syncDebounceTimer = null;
- }, 750); // Delay of 750ms after typing stops
- }
-
- return {
- isInitializing,
- initializationError,
- initializeFromUrl,
- syncUrlFromState,
- debouncedSyncUrlFromState,
- pushQueryHistoryEntry,
- };
-}
diff --git a/frontend/src/composables/useFormHandling.ts b/frontend/src/composables/useFormHandling.ts
deleted file mode 100644
index e48e395a8..000000000
--- a/frontend/src/composables/useFormHandling.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { ref } from 'vue'
-import { useToast } from '@/composables/useToast'
-import { TOAST_DURATION } from '@/lib/constants'
-
-export function useFormHandling(
- storeAction: (payload: T) => Promise,
- options?: {
- successMessage?: string;
- errorMessage?: string;
- onSuccess?: (result: R) => void;
- onError?: (error: Error) => void;
- }
-) {
- const { toast } = useToast()
- const isSubmitting = ref(false)
- const formError = ref(null)
-
- const handleSubmit = async (payload: T) => {
- if (isSubmitting.value) return { success: false }
-
- isSubmitting.value = true
- formError.value = null
-
- try {
- const result = await storeAction(payload)
-
- if (options?.onSuccess) {
- options.onSuccess(result)
- }
-
- return { success: true, result }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'
- formError.value = errorMessage
-
- if (options?.errorMessage) {
- toast({
- title: 'Error',
- description: options.errorMessage,
- variant: 'destructive',
- duration: TOAST_DURATION.ERROR,
- })
- }
-
- if (options?.onError && error instanceof Error) {
- options.onError(error)
- }
-
- return { success: false, error: errorMessage }
- } finally {
- isSubmitting.value = false
- }
- }
-
- return {
- isSubmitting,
- formError,
- handleSubmit,
- }
-}
diff --git a/frontend/src/composables/useQuery.ts b/frontend/src/composables/useQuery.ts
index 58e9ff3c5..2cac7dad7 100644
--- a/frontend/src/composables/useQuery.ts
+++ b/frontend/src/composables/useQuery.ts
@@ -7,7 +7,6 @@ import { SqlManager } from '@/services/SqlManager';
import { getErrorMessage } from '@/api/types';
import type { TimeRange } from '@/types/query';
import { logchefqlApi } from '@/api/logchefql';
-import { useExploreUrlSync } from '@/composables/useExploreUrlSync';
import { useVariables } from "@/composables/useVariables";
// Define the valid editor modes
@@ -29,7 +28,6 @@ export function useQuery() {
const exploreStore = useExploreStore();
const sourcesStore = useSourcesStore();
const teamsStore = useTeamsStore();
- const { syncUrlFromState } = useExploreUrlSync();
const { convertVariables } = useVariables();
// Local state that isn't persisted in the store
const queryError = ref('');
@@ -212,9 +210,6 @@ export function useQuery() {
// Delegate to store action
exploreStore.setActiveMode(newMode);
-
- // After mode switch, sync URL state
- syncUrlFromState();
};
// Handle time range update
@@ -243,9 +238,6 @@ export function useQuery() {
// In LogchefQL mode, the limit is passed to the backend separately
// No need to modify the LogchefQL query itself
-
- // Sync URL state after update
- syncUrlFromState();
};
// Generate default SQL - now uses SqlManager
@@ -402,10 +394,7 @@ export function useQuery() {
// Execute via store action
const execResult = await exploreStore.executeQuery();
- // Update URL state after successful execution
- if (execResult.success) {
- syncUrlFromState();
- } else {
+ if (!execResult.success) {
queryError.value = execResult.error?.message || 'Query execution failed';
}
diff --git a/frontend/src/composables/useRouteSync.ts b/frontend/src/composables/useRouteSync.ts
deleted file mode 100644
index 01b1bda96..000000000
--- a/frontend/src/composables/useRouteSync.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { ref } from 'vue';
-import { useRoute, useRouter } from 'vue-router';
-import { useTeamsStore } from '@/stores/teams';
-import { useSourcesStore } from '@/stores/sources';
-import { useExploreStore } from '@/stores/explore';
-
-export function useRouteSync() {
- const route = useRoute();
- const router = useRouter();
- const teamsStore = useTeamsStore();
- const sourcesStore = useSourcesStore();
- const exploreStore = useExploreStore();
-
- const isHydrating = ref(false);
- const hydrationError = ref(null);
-
- async function ensureTeamsLoaded() {
- if (!teamsStore.teams.length) {
- await teamsStore.loadUserTeams();
- }
- if (!teamsStore.currentTeamId && teamsStore.teams.length) {
- teamsStore.setCurrentTeam(teamsStore.teams[0].id);
- }
- }
-
- function parseTeamFromUrl(): number | null {
- const t = route.query.team as string | undefined;
- if (!t) return null;
- const id = parseInt(t, 10);
- return isNaN(id) ? null : id;
- }
-
- function parseSourceFromUrl(): number | null {
- const s = route.query.source as string | undefined;
- if (!s) return null;
- const id = parseInt(s, 10);
- return isNaN(id) ? null : id;
- }
-
- async function hydrateFromUrl() {
- isHydrating.value = true;
- hydrationError.value = null;
- try {
- // Teams
- await ensureTeamsLoaded();
- let teamId = parseTeamFromUrl();
- if (teamId && !teamsStore.userBelongsToTeam(teamId)) {
- teamId = null;
- }
- if (!teamId && teamsStore.teams.length) {
- teamId = teamsStore.teams[0].id;
- }
- if (teamId && teamsStore.currentTeamId !== teamId) {
- teamsStore.setCurrentTeam(teamId);
- }
-
- // Sources for team
- if (teamsStore.currentTeamId) {
- await sourcesStore.loadTeamSources(teamsStore.currentTeamId);
- }
-
- // Source from URL or first
- const urlSource = parseSourceFromUrl();
- let sourceId: number | null = null;
- if (urlSource && sourcesStore.teamSources.some(s => s.id === urlSource)) {
- sourceId = urlSource;
- } else if (sourcesStore.teamSources.length) {
- sourceId = sourcesStore.teamSources[0].id;
- }
-
- if (sourceId) {
- if (exploreStore.sourceId !== sourceId) {
- // mark origin as url to avoid auto overrides
- (exploreStore as any).setSource(sourceId, { origin: 'url' });
- }
- await sourcesStore.loadSourceDetails(sourceId);
- } else {
- exploreStore.setSource(0 as any);
- sourcesStore.clearCurrentSourceDetails();
- }
-
- // Initialize remaining explore state from URL
- const params: Record = {};
- const q = route.query;
- if (q.source) params.source = String(q.source);
- if (q.time) params.time = String(q.time);
- if (q.start) params.start = String(q.start);
- if (q.end) params.end = String(q.end);
- if (q.limit) params.limit = String(q.limit);
- if (q.mode) params.mode = String(q.mode);
- if (q.q) params.q = String(q.q);
- if (q.sql) params.sql = String(q.sql);
- if (q.query_id) params.query_id = String(q.query_id);
-
- exploreStore.initializeFromUrl(params);
- } catch (e: any) {
- hydrationError.value = e?.message || 'Failed to hydrate from URL';
- } finally {
- isHydrating.value = false;
- }
- }
-
- async function changeTeam(teamId: number) {
- if (teamsStore.currentTeamId !== teamId) {
- teamsStore.setCurrentTeam(teamId);
- }
- await sourcesStore.loadTeamSources(teamId);
- const first = sourcesStore.teamSources[0]?.id;
- if (first) {
- await changeSource(first);
- }
- await router.replace({ query: { ...route.query, team: String(teamId), source: first ? String(first) : undefined } });
- }
-
- async function changeSource(sourceId: number) {
- if (exploreStore.sourceId !== sourceId) {
- (exploreStore as any).setSource(sourceId, { origin: 'user' });
- }
- await sourcesStore.loadSourceDetails(sourceId);
- await router.replace({ query: { ...route.query, source: String(sourceId) } });
- }
-
- return {
- isHydrating,
- hydrationError,
- hydrateFromUrl,
- changeTeam,
- changeSource,
- };
-}
diff --git a/frontend/src/composables/useSavedQueries.ts b/frontend/src/composables/useSavedQueries.ts
index 012b2d967..0a8b1650b 100644
--- a/frontend/src/composables/useSavedQueries.ts
+++ b/frontend/src/composables/useSavedQueries.ts
@@ -49,7 +49,7 @@ export function useSavedQueries(
const isLoadingQueryDetails = ref(false)
const searchQuery = ref('')
- const isEditingExistingQuery = computed(() => !!route.query.collection_id);
+ const isEditingExistingQuery = computed(() => !!route.query.id);
const canManageCollections = computed(() => {
if (!authStore.isAuthenticated || !authStore.user) {
@@ -124,8 +124,7 @@ export function useSavedQueries(
return
}
- // Check if we have a query_id in the URL, which means we're editing an existing query
- const queryId = route.query.query_id
+ const queryId = route.query.id
if (queryId) {
// We are editing an existing query - load the query details
const teamId = route.query.team as string
@@ -185,8 +184,7 @@ export function useSavedQueries(
try {
let response;
- // Check if we're updating an existing query from the URL or editingQuery state
- const queryIdFromUrl = route.query.query_id as string | undefined;
+ const queryIdFromUrl = route.query.id as string | undefined;
const isUpdate = !!editingQuery.value || !!queryIdFromUrl;
const queryId = editingQuery.value?.id.toString() || queryIdFromUrl;
@@ -292,30 +290,21 @@ export function useSavedQueries(
exploreStore.setActiveSavedQueryName(savedQueryName);
}
- // Add this: Set the selectedQueryId in the store
if (response.data && response.data.id) {
- // Save the query ID to the store
exploreStore.setSelectedQueryId(response.data.id.toString());
- // Update URL with the new query_id
- const currentQuery = { ...route.query };
- currentQuery.query_id = response.data.id.toString();
- router.replace({ query: currentQuery });
+ router.replace({
+ query: {
+ team: formData.team_id?.toString(),
+ source: formData.source_id?.toString(),
+ id: response.data.id.toString(),
+ }
+ });
}
- // Ensure queries are refreshed for the current source
if (formData.team_id && formData.source_id) {
await loadSourceQueries(formData.team_id, formData.source_id);
}
-
- // Only clear query_id from URL if we were editing and now want to create a new one
- // NOT when we just created a new query
- if (queryIdFromUrl && response.data && response.data.id && queryIdFromUrl !== response.data.id.toString()) {
- const currentQuery = { ...route.query };
- // Update to the new query_id instead of deleting it
- currentQuery.query_id = response.data.id.toString();
- router.replace({ query: currentQuery });
- }
return { success: true, data: response.data }; // Return success state
} else if (response) {
// Handle failure from the store action
@@ -369,25 +358,19 @@ export function useSavedQueries(
// Set limit if available
if (content.limit) exploreStore.setLimit(content.limit)
- // Check if timeRange is explicitly null - this means to keep the current time range
if (content.timeRange === null) {
console.log("Saved query has timeRange explicitly set to null, keeping current range");
- // Keep the current time range from the store
- }
- // Set time range from saved query if available and valid
- else if (content.timeRange &&
- content.timeRange.absolute &&
- content.timeRange.absolute.start &&
- content.timeRange.absolute.end) {
- console.log("Setting time range from saved query:", content.timeRange);
-
- // Convert timestamps to CalendarDateTime objects
+ } else if (content.timeRange?.relative) {
+ console.log("Setting relative time range from saved query:", content.timeRange.relative);
+ exploreStore.setRelativeTimeRange(content.timeRange.relative);
+ } else if (content.timeRange?.absolute?.start && content.timeRange?.absolute?.end) {
+ console.log("Setting absolute time range from saved query:", content.timeRange);
+
try {
const startDate = new Date(content.timeRange.absolute.start);
const endDate = new Date(content.timeRange.absolute.end);
if (!isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) {
- // Create CalendarDateTime objects
const startDateTime = new CalendarDateTime(
startDate.getFullYear(),
startDate.getMonth() + 1,
@@ -406,7 +389,6 @@ export function useSavedQueries(
endDate.getSeconds()
);
- // Set the time range in the store
exploreStore.setTimeConfiguration({
absoluteRange: {
start: startDateTime,
@@ -421,7 +403,6 @@ export function useSavedQueries(
}
} else {
console.log("Saved query has no time range specified or it's invalid, keeping current range");
- // Keep existing time range from the store
}
// save variable data into store.
@@ -445,50 +426,17 @@ export function useSavedQueries(
exploreStore.setActiveSavedQueryName(queryData.name);
}
- // CENTRALIZED URL HANDLING: Create URL query parameters directly
- // This ensures consistency between dropdown and saved queries view
- const queryParams: Record = {};
-
- // Always include these critical parameters for proper state tracking
- queryParams.team = queryData.team_id.toString();
- queryParams.source = queryData.source_id.toString();
- queryParams.query_id = queryData.id.toString(); // Most important - makes "New Query" button appear
-
- // Set time range params from the current exploreStore state (after we've updated it)
- const startTime = calendarDateTimeToTimestamp(exploreStore.timeRange?.start);
- const endTime = calendarDateTimeToTimestamp(exploreStore.timeRange?.end);
- if (startTime !== null && endTime !== null) {
- // Use canonical keys expected by explorer
- queryParams.start = startTime.toString();
- queryParams.end = endTime.toString();
- }
-
- // Set limit from current store state
- queryParams.limit = exploreStore.limit.toString();
+ const queryParams: Record = {
+ team: queryData.team_id.toString(),
+ source: queryData.source_id.toString(),
+ id: queryData.id.toString(),
+ };
- // Set mode and query content
- queryParams.mode = isLogchefQL ? 'logchefql' : 'sql';
- if (queryToLoad) {
- // For SQL mode use `sql`, for logchefql use `q`
- if (isLogchefQL) {
- queryParams.q = queryToLoad;
- } else {
- queryParams.sql = queryToLoad;
- }
+ const currentId = route.query.id as string | undefined;
+ if (currentId !== queryData.id.toString()) {
+ router.replace({ query: queryParams });
}
- // Update URL with complete state (replaces syncUrlFromState call)
- console.log("Updating URL with saved query state, including query_id:", queryData.id.toString());
- router.replace({ query: queryParams });
-
- // toast({
- // title: 'Success',
- // description: `Query "${queryData.name}" loaded successfully.`,
- // duration: TOAST_DURATION.SUCCESS
- // })
-
- // Don't call syncUrlFromState() since we're explicitly setting the URL
-
return true
} catch (error) {
console.error('Error loading saved query:', error)
@@ -506,66 +454,19 @@ export function useSavedQueries(
}
}
- // Generate URL for a saved query
function getQueryUrl(query: SavedTeamQuery): string {
- try {
- // Get query type from the saved query - ensure it's normalized
- const queryType = query.query_type?.toLowerCase() === 'logchefql' ? 'logchefql' : 'sql'
- console.log(`Building URL for query ${query.id} with type ${queryType}`)
-
- // Parse the query content
- const queryContent = JSON.parse(query.query_content)
-
- // Build the URL with the appropriate parameters
- let url = `/logs/explore?team=${query.team_id}`
-
- // Add source ID if available
- if (query.source_id) {
- url += `&source=${query.source_id}`
- }
-
- // Add query ID for editing
- url += `&query_id=${query.id}`
-
- // Add limit if available
- if (queryContent.limit) {
- url += `&limit=${queryContent.limit}`
- }
-
- // Always add time range if available - this is crucial for query execution
- if (queryContent.timeRange !== null &&
- queryContent.timeRange?.absolute?.start &&
- queryContent.timeRange?.absolute?.end) {
- url += `&start=${queryContent.timeRange.absolute.start}`
- url += `&end=${queryContent.timeRange.absolute.end}`
- }
-
- // Add mode parameter based on query type
- url += `&mode=${queryType}`
-
- // Add the query content (actual query text)
- if (queryContent.content) {
- if (queryType === 'logchefql') {
- url += `&q=${encodeURIComponent(queryContent.content)}`
- } else {
- url += `&sql=${encodeURIComponent(queryContent.content)}`
- }
- }
-
- return url
- } catch (error) {
- console.error('Error generating query URL:', error)
- // Fallback URL with explicit mode parameter
- return `/logs/explore?team=${query.team_id}&source=${query.source_id}&mode=${query.query_type?.toLowerCase() === 'logchefql' ? 'logchefql' : 'sql'}`
- }
+ return `/logs/collection/${query.team_id}/${query.source_id}/${query.id}`
}
- // Handle opening query in explorer
function openQuery(query: SavedTeamQuery) {
- const url = getQueryUrl(query)
- // Always use router.push to create a proper history entry
- // This ensures the back button works correctly when navigating between queries
- router.push(url)
+ router.push({
+ path: '/logs/explore',
+ query: {
+ team: query.team_id.toString(),
+ source: query.source_id.toString(),
+ id: query.id.toString(),
+ },
+ })
}
// Handle edit query
@@ -598,10 +499,9 @@ export function useSavedQueries(
exploreStore.setActiveSavedQueryName(null);
exploreStore.setSelectedQueryId(null);
- // Remove query_id from URL if present
- if (route.query.query_id) {
+ if (route.query.id) {
const currentQuery = { ...route.query };
- delete currentQuery.query_id;
+ delete currentQuery.id;
router.replace({ query: currentQuery });
}
}
@@ -673,7 +573,7 @@ export function useSavedQueries(
// Use the centralized reset function in the store
exploreStore.resetQueryToDefaults();
- // Build new query parameters without query_id
+ // Build new query parameters without saved query id
const newQuery: Record = {};
// Keep the current team if available
diff --git a/frontend/src/composables/useTimeRange.ts b/frontend/src/composables/useTimeRange.ts
index abe29394b..28a2a85a4 100644
--- a/frontend/src/composables/useTimeRange.ts
+++ b/frontend/src/composables/useTimeRange.ts
@@ -117,12 +117,24 @@ export function useTimeRange() {
// Handle histogram time range zooming
const handleHistogramTimeRangeZoom = (range: { start: Date; end: Date }) => {
try {
- // Convert native Dates directly to CalendarDateTime
+ const currentRange = exploreStore.timeRange;
+ if (currentRange) {
+ const currentStart = calendarDateTimeToTimestamp(currentRange.start);
+ const currentEnd = calendarDateTimeToTimestamp(currentRange.end);
+ const newStart = range.start.getTime();
+ const newEnd = range.end.getTime();
+
+ const isSameRange = Math.abs((currentStart || 0) - newStart) < 1000 &&
+ Math.abs((currentEnd || 0) - newEnd) < 1000;
+
+ if (isSameRange) {
+ return false;
+ }
+ }
+
const start = toCalendarDateTime(fromDate(range.start, getLocalTimeZone()));
const end = toCalendarDateTime(fromDate(range.end, getLocalTimeZone()));
- // Update the store's time range using the appropriate action
- // Use setTimeConfiguration as this is an absolute range selection
exploreStore.setTimeConfiguration({
absoluteRange: { start, end }
});
diff --git a/frontend/src/composables/useUrlState.ts b/frontend/src/composables/useUrlState.ts
new file mode 100644
index 000000000..889f55827
--- /dev/null
+++ b/frontend/src/composables/useUrlState.ts
@@ -0,0 +1,243 @@
+import { ref, computed, watch, type Ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { useExploreStore } from '@/stores/explore';
+import { useTeamsStore } from '@/stores/teams';
+import { useSourcesStore } from '@/stores/sources';
+import { useContextStore } from '@/stores/context';
+import { savedQueriesApi } from '@/api/savedQueries';
+
+export type UrlSyncState = 'idle' | 'loading' | 'ready' | 'error';
+
+interface UrlStateReturn {
+ state: Ref;
+ error: Ref;
+ isReady: Ref;
+ initialize: () => Promise;
+ pushHistoryEntry: () => void;
+}
+
+export function useUrlState(): UrlStateReturn {
+ const route = useRoute();
+ const router = useRouter();
+ const exploreStore = useExploreStore();
+ const teamsStore = useTeamsStore();
+ const sourcesStore = useSourcesStore();
+ const contextStore = useContextStore();
+
+ const state = ref('idle');
+ const error = ref(null);
+ const isReady = computed(() => state.value === 'ready');
+
+ const pendingQueryResolve = ref(false);
+ let skipNextSync = false;
+
+ function checkReadiness(): boolean {
+ if (!teamsStore.teams || teamsStore.teams.length === 0) {
+ return false;
+ }
+
+ if (!contextStore.teamId) {
+ return false;
+ }
+
+ if (sourcesStore.isLoadingTeamSources) {
+ return false;
+ }
+
+ if (contextStore.sourceId && sourcesStore.isLoadingSourceDetails) {
+ return false;
+ }
+
+ if (pendingQueryResolve.value) {
+ return false;
+ }
+
+ return true;
+ }
+
+ async function initialize(): Promise {
+ if (state.value === 'loading') {
+ return;
+ }
+
+ state.value = 'loading';
+ error.value = null;
+ let shouldExecute = false;
+
+ try {
+ if (!teamsStore.teams || teamsStore.teams.length === 0) {
+ await teamsStore.loadTeams(false, false);
+ }
+
+ if (teamsStore.teams.length === 0) {
+ error.value = 'No teams available or accessible.';
+ state.value = 'error';
+ return;
+ }
+
+ const params = route.query as Record;
+
+ if (!params.team || !params.source) {
+ const storedDefaults = contextStore.getStoredDefaults();
+ const teamId = params.team
+ ? parseInt(params.team, 10)
+ : (storedDefaults.teamId ?? teamsStore.currentTeamId ?? teamsStore.teams?.[0]?.id);
+
+ let sourceId = params.source ? parseInt(params.source, 10) : null;
+
+ if (!sourceId && teamId) {
+ sourceId = storedDefaults.sourceId ?? contextStore.getStoredSourceForTeam(teamId);
+ if (!sourceId && sourcesStore.teamSources?.length > 0) {
+ sourceId = sourcesStore.teamSources[0].id;
+ }
+ }
+
+ if (teamId && (!params.team || (!params.source && sourceId))) {
+ const newQuery: Record = { ...route.query as Record };
+ if (!params.team) newQuery.team = teamId.toString();
+ if (!params.source && sourceId) newQuery.source = sourceId.toString();
+
+ await router.replace({ query: newQuery });
+ params.team = newQuery.team;
+ params.source = newQuery.source;
+ }
+ }
+
+ const result = exploreStore.initializeFromUrl(params);
+ shouldExecute = result.shouldExecute;
+
+ if (result.needsResolve && result.queryId) {
+ pendingQueryResolve.value = true;
+
+ try {
+ const teamId = teamsStore.currentTeamId;
+ const sourceId = exploreStore.sourceId;
+
+ if (teamId && sourceId) {
+ const response = await savedQueriesApi.resolveQuery(
+ teamId,
+ sourceId,
+ parseInt(result.queryId)
+ );
+
+ if (response.data) {
+ const hydrateResult = exploreStore.hydrateFromResolvedQuery(response.data);
+ shouldExecute = hydrateResult.shouldExecute;
+ }
+ }
+ } finally {
+ pendingQueryResolve.value = false;
+ }
+ }
+
+ await waitForReadiness();
+ state.value = 'ready';
+
+ if (shouldExecute) {
+ exploreStore.executeQuery().catch(err => {
+ console.error('useUrlState: Error executing initial query:', err);
+ });
+ }
+
+ } catch (err: any) {
+ console.error('useUrlState: Initialization error:', err);
+ error.value = err.message || 'Failed to initialize from URL.';
+ state.value = 'error';
+ }
+ }
+
+ function waitForReadiness(maxWaitMs = 5000): Promise {
+ if (checkReadiness()) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve) => {
+ const timeout = setTimeout(() => {
+ stopWatch();
+ console.warn('useUrlState: Readiness timeout, proceeding anyway');
+ resolve();
+ }, maxWaitMs);
+
+ const stopWatch = watch(
+ [
+ () => teamsStore.teams,
+ () => teamsStore.currentTeamId,
+ () => sourcesStore.isLoadingTeamSources,
+ () => sourcesStore.isLoadingSourceDetails,
+ () => pendingQueryResolve.value,
+ ],
+ () => {
+ if (checkReadiness()) {
+ clearTimeout(timeout);
+ stopWatch();
+ resolve();
+ }
+ },
+ { immediate: true }
+ );
+ });
+ }
+
+ function syncUrlFromStore(): void {
+ if (state.value !== 'ready') {
+ return;
+ }
+
+ if (skipNextSync) {
+ skipNextSync = false;
+ return;
+ }
+
+ const query = exploreStore.urlQueryParameters;
+ const currentQuery = route.query;
+
+ const queryChanged = JSON.stringify(query) !== JSON.stringify(currentQuery);
+
+ if (queryChanged) {
+ router.replace({ query }).catch(err => {
+ if (err.name !== 'NavigationDuplicated') {
+ console.error('useUrlState: Error updating URL:', err);
+ }
+ });
+ }
+ }
+
+ function pushHistoryEntry(): void {
+ if (state.value !== 'ready') {
+ return;
+ }
+
+ skipNextSync = true;
+ const query = exploreStore.urlQueryParameters;
+
+ router.push({ query }).catch(err => {
+ if (err.name !== 'NavigationDuplicated') {
+ console.error('useUrlState: Error pushing history:', err);
+ }
+ });
+ }
+
+ watch(
+ [
+ () => teamsStore.currentTeamId,
+ () => exploreStore.sourceId,
+ () => exploreStore.limit,
+ () => exploreStore.timeRange,
+ () => exploreStore.selectedRelativeTime,
+ () => exploreStore.activeMode,
+ () => exploreStore.selectedQueryId,
+ ],
+ () => {
+ syncUrlFromStore();
+ },
+ { deep: true }
+ );
+
+ return {
+ state,
+ error,
+ isReady,
+ initialize,
+ pushHistoryEntry,
+ };
+}
diff --git a/frontend/src/layouts/InnerApp.vue b/frontend/src/layouts/InnerApp.vue
index 05192d51d..1e81d626d 100644
--- a/frontend/src/layouts/InnerApp.vue
+++ b/frontend/src/layouts/InnerApp.vue
@@ -88,11 +88,37 @@ const navigateToCollections = () => {
const explorerTo = computed(() => {
const team = teamsStore.currentTeamId ? teamsStore.currentTeamId.toString() : undefined;
+ const source = exploreStore.sourceId ? exploreStore.sourceId.toString() : undefined;
+ const query: Record = {};
+ if (team) query.team = team;
+ if (source) query.source = source;
return {
path: "/logs/explore",
- query: {
- ...(team ? { team } : {}),
- },
+ query,
+ };
+});
+
+const alertsTo = computed(() => {
+ const team = teamsStore.currentTeamId ? teamsStore.currentTeamId.toString() : undefined;
+ const source = exploreStore.sourceId ? exploreStore.sourceId.toString() : undefined;
+ const query: Record = {};
+ if (team) query.team = team;
+ if (source) query.source = source;
+ return {
+ path: "/logs/alerts",
+ query,
+ };
+});
+
+const collectionsTo = computed(() => {
+ const team = teamsStore.currentTeamId ? teamsStore.currentTeamId.toString() : undefined;
+ const source = exploreStore.sourceId ? exploreStore.sourceId.toString() : undefined;
+ const query: Record = {};
+ if (team) query.team = team;
+ if (source) query.source = source;
+ return {
+ path: "/logs/saved",
+ query,
};
});
@@ -266,7 +292,7 @@ const navItems = [
- Loading collection...
+ + + + +{{ error }}
+