diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6c9754319f..fd2f3c016a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -83,7 +83,9 @@ import { DataWarehouseJobStatsRequestPayload, DataWarehouseManagedViewsetSavedQuery, DataWarehouseSavedQuery, + DataWarehouseSavedQueryDependencies, DataWarehouseSavedQueryDraft, + DataWarehouseSavedQueryRunHistory, DataWarehouseSourceRowCount, DataWarehouseTable, DataWarehouseViewLink, @@ -3753,6 +3755,14 @@ const api = { .withAction('descendants') .create({ data: { level } }) }, + async dependencies(viewId: DataWarehouseSavedQuery['id']): Promise { + return await new ApiRequest().dataWarehouseSavedQuery(viewId).withAction('dependencies').get() + }, + async runHistory( + viewId: DataWarehouseSavedQuery['id'] + ): Promise<{ run_history: DataWarehouseSavedQueryRunHistory[] }> { + return await new ApiRequest().dataWarehouseSavedQuery(viewId).withAction('run_history').get() + }, dataWarehouseDataModelingJobs: { async list( savedQueryId: DataWarehouseSavedQuery['id'], diff --git a/frontend/src/scenes/data-warehouse/DataWarehouseScene.tsx b/frontend/src/scenes/data-warehouse/DataWarehouseScene.tsx index ba442bc9c6..69a7e382d0 100644 --- a/frontend/src/scenes/data-warehouse/DataWarehouseScene.tsx +++ b/frontend/src/scenes/data-warehouse/DataWarehouseScene.tsx @@ -17,6 +17,7 @@ import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection' import { DataWarehouseTab, dataWarehouseSceneLogic } from './dataWarehouseSceneLogic' import { OverviewTab } from './scene/OverviewTab' import { SourcesTab } from './scene/SourcesTab' +import { ViewsTab } from './scene/ViewsTab' export const scene: SceneExport = { component: DataWarehouseScene, logic: dataWarehouseSceneLogic } @@ -68,6 +69,11 @@ export function DataWarehouseScene(): JSX.Element { label: 'Sources', content: , }, + { + key: DataWarehouseTab.VIEWS, + label: 'Views', + content: , + }, ]} /> diff --git a/frontend/src/scenes/data-warehouse/dataWarehouseSceneLogic.ts b/frontend/src/scenes/data-warehouse/dataWarehouseSceneLogic.ts index 3c0425effd..9aba224c85 100644 --- a/frontend/src/scenes/data-warehouse/dataWarehouseSceneLogic.ts +++ b/frontend/src/scenes/data-warehouse/dataWarehouseSceneLogic.ts @@ -25,6 +25,7 @@ const REFRESH_INTERVAL = 10000 export enum DataWarehouseTab { OVERVIEW = 'overview', SOURCES = 'sources', + VIEWS = 'views', } export const dataWarehouseSceneLogic = kea([ diff --git a/frontend/src/scenes/data-warehouse/scene/ViewsTab.tsx b/frontend/src/scenes/data-warehouse/scene/ViewsTab.tsx new file mode 100644 index 0000000000..2461f4f121 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/scene/ViewsTab.tsx @@ -0,0 +1,313 @@ +import { useActions, useValues } from 'kea' + +import { LemonButton, LemonInput, LemonTable, LemonTag, LemonTagType, Spinner, Tooltip } from '@posthog/lemon-ui' + +import { TZLabel } from 'lib/components/TZLabel' +import { More } from 'lib/lemon-ui/LemonButton/More' +import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink' +import { humanFriendlyDetailedTime } from 'lib/utils' +import { urls } from 'scenes/urls' + +import { DataWarehouseSavedQuery, DataWarehouseSavedQueryRunHistory } from '~/types' + +import { viewsTabLogic } from './viewsTabLogic' + +const STATUS_TAG_SETTINGS: Record = { + Running: 'primary', + Completed: 'success', + Failed: 'danger', + Cancelled: 'muted', + Modified: 'warning', +} + +function RunHistoryDisplay({ + runHistory, + loading, +}: { + runHistory?: DataWarehouseSavedQueryRunHistory[] + loading?: boolean +}): JSX.Element { + if (loading) { + return + } + + if (!runHistory || runHistory.length === 0) { + return - + } + + // Show up to 5 most recent runs + const displayRuns = runHistory.slice(0, 5) + + return ( +
+ {displayRuns.map((run, index) => { + const friendlyTime = run.timestamp ? humanFriendlyDetailedTime(run.timestamp) : '' + return ( + +
+ + ) + })} +
+ ) +} + +function DependencyCount({ count, loading }: { count?: number; loading?: boolean }): JSX.Element { + if (loading || count === undefined) { + return + } + return {count} +} + +export function ViewsTab(): JSX.Element { + const { + filteredViews, + filteredMaterializedViews, + viewsLoading, + searchTerm, + dependenciesMapLoading, + runHistoryMapLoading, + materializedViewsCurrentPage, + viewsCurrentPage, + } = useValues(viewsTabLogic) + const { setSearchTerm, deleteView, runMaterialization, setMaterializedViewsPage, setViewsPage } = + useActions(viewsTabLogic) + + return ( +
+ {(filteredViews.length > 0 || filteredMaterializedViews.length > 0 || searchTerm) && ( +
+ +
+ )} + + {/* Materialized Views Section */} + {filteredMaterializedViews.length > 0 && ( +
+

Materialized views

+

+ Materialized views are refreshed on a schedule and stored as tables for faster query + performance. +

+ ( + + ), + }, + { + title: 'Last run', + key: 'last_run_at', + render: (_, view) => { + return view.last_run_at ? ( + + ) : ( + 'Never' + ) + }, + }, + { + title: 'Status', + key: 'status', + render: (_, view) => { + if (!view.status) { + return null + } + const tagContent = ( + + {view.status} + + ) + return view.latest_error && view.status === 'Failed' ? ( + {tagContent} + ) : ( + tagContent + ) + }, + }, + { + title: 'Run history', + key: 'run_history', + tooltip: 'Recent run status (up to 5 most recent)', + render: (_, view) => ( + + ), + }, + { + title: 'Upstream', + key: 'upstream_count', + tooltip: 'Number of immediate upstream dependencies', + render: (_, view) => ( + + ), + }, + { + title: 'Downstream', + key: 'downstream_count', + tooltip: 'Number of immediate downstream dependencies', + render: (_, view) => ( + + ), + }, + { + key: 'actions', + width: 0, + render: (_, view) => ( + + runMaterialization(view.id)}> + Run now + + deleteView(view.id)}> + Delete + + + } + /> + ), + }, + ]} + pagination={{ + pageSize: 10, + currentPage: materializedViewsCurrentPage, + onForward: () => { + setMaterializedViewsPage(materializedViewsCurrentPage + 1) + }, + onBackward: () => { + setMaterializedViewsPage(materializedViewsCurrentPage - 1) + }, + }} + /> +
+ )} + + {/* Regular Views Section */} + {filteredViews.length > 0 && ( +
+

Views

+

+ Views are virtual tables created from SQL queries. They are computed on-the-fly when queried. +

+ ( + + ), + }, + { + title: 'Created', + key: 'created_at', + render: (_, view) => + view.created_at ? ( + + ) : ( + '-' + ), + }, + { + title: 'Upstream', + key: 'upstream_count', + tooltip: 'Number of immediate upstream dependencies', + render: (_, view) => ( + + ), + }, + { + title: 'Downstream', + key: 'downstream_count', + tooltip: 'Number of immediate downstream dependencies', + render: (_, view) => ( + + ), + }, + { + key: 'actions', + width: 0, + render: (_, view) => ( + + deleteView(view.id)}> + Delete + + + } + /> + ), + }, + ]} + pagination={{ + pageSize: 10, + currentPage: viewsCurrentPage, + onForward: () => { + setViewsPage(viewsCurrentPage + 1) + }, + onBackward: () => { + setViewsPage(viewsCurrentPage - 1) + }, + }} + /> +
+ )} + + {/* Empty State */} + {!viewsLoading && filteredViews.length === 0 && filteredMaterializedViews.length === 0 && ( +
+

No views found

+ {searchTerm ? ( +

No views match your search. Try adjusting your search term.

+ ) : ( +

+ Create your first view to transform and organize your data warehouse tables. +

+ )} + + Create view + +
+ )} +
+ ) +} diff --git a/frontend/src/scenes/data-warehouse/scene/viewsTabLogic.ts b/frontend/src/scenes/data-warehouse/scene/viewsTabLogic.ts new file mode 100644 index 0000000000..f29d2465eb --- /dev/null +++ b/frontend/src/scenes/data-warehouse/scene/viewsTabLogic.ts @@ -0,0 +1,232 @@ +import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { loaders } from 'kea-loaders' + +import { LemonDialog } from '@posthog/lemon-ui' + +import api from 'lib/api' +import { lemonToast } from 'lib/lemon-ui/LemonToast' + +import { + DataWarehouseSavedQuery, + DataWarehouseSavedQueryDependencies, + DataWarehouseSavedQueryRunHistory, +} from '~/types' + +import { dataWarehouseViewsLogic } from '../saved_queries/dataWarehouseViewsLogic' +import type { viewsTabLogicType } from './viewsTabLogicType' + +const PAGE_SIZE = 10 + +export const viewsTabLogic = kea([ + path(['scenes', 'data-warehouse', 'scene', 'viewsTabLogic']), + connect(() => ({ + values: [dataWarehouseViewsLogic, ['dataWarehouseSavedQueries', 'dataWarehouseSavedQueriesLoading']], + actions: [dataWarehouseViewsLogic, ['deleteDataWarehouseSavedQuery', 'runDataWarehouseSavedQuery']], + })), + actions({ + setSearchTerm: (searchTerm: string) => ({ searchTerm }), + deleteView: (viewId: string) => ({ viewId }), + runMaterialization: (viewId: string) => ({ viewId }), + loadDependenciesForViews: (viewIds: string[]) => ({ viewIds }), + loadRunHistoryForViews: (viewIds: string[]) => ({ viewIds }), + setMaterializedViewsPage: (page: number) => ({ page }), + setViewsPage: (page: number) => ({ page }), + loadVisibleData: true, + }), + reducers({ + searchTerm: [ + '' as string, + { + setSearchTerm: (_, { searchTerm }) => searchTerm, + }, + ], + materializedViewsCurrentPage: [ + 1 as number, + { + setMaterializedViewsPage: (_, { page }) => page, + setSearchTerm: () => 1, // Reset to page 1 on search + }, + ], + viewsCurrentPage: [ + 1 as number, + { + setViewsPage: (_, { page }) => page, + setSearchTerm: () => 1, // Reset to page 1 on search + }, + ], + }), + loaders(({ values }) => ({ + dependenciesMap: [ + {} as Record, + { + loadDependenciesForViews: async ({ viewIds }) => { + // Filter out views we've already loaded + const viewsToLoad = viewIds.filter((id) => !values.dependenciesMap[id]) + + if (viewsToLoad.length === 0) { + return values.dependenciesMap + } + + const results = await Promise.all( + viewsToLoad.map(async (viewId) => { + try { + const data = await api.dataWarehouseSavedQueries.dependencies(viewId) + return { viewId, data } + } catch (error) { + console.error(`Failed to load dependencies for view ${viewId}:`, error) + return { viewId, data: { upstream_count: 0, downstream_count: 0 } } + } + }) + ) + + const newMap = { ...values.dependenciesMap } + results.forEach(({ viewId, data }) => { + newMap[viewId] = data + }) + return newMap + }, + }, + ], + runHistoryMap: [ + {} as Record, + { + loadRunHistoryForViews: async ({ viewIds }) => { + // Filter out views we've already loaded + const viewsToLoad = viewIds.filter((id) => !values.runHistoryMap[id]) + + if (viewsToLoad.length === 0) { + return values.runHistoryMap + } + + const results = await Promise.all( + viewsToLoad.map(async (viewId) => { + try { + const data = await api.dataWarehouseSavedQueries.runHistory(viewId) + return { viewId, data: data.run_history } + } catch (error) { + console.error(`Failed to load run history for view ${viewId}:`, error) + return { viewId, data: [] } + } + }) + ) + + const newMap = { ...values.runHistoryMap } + results.forEach(({ viewId, data }) => { + newMap[viewId] = data + }) + return newMap + }, + }, + ], + })), + selectors({ + viewsLoading: [(s) => [s.dataWarehouseSavedQueriesLoading], (loading): boolean => loading], + enrichedQueries: [ + (s) => [s.dataWarehouseSavedQueries, s.dependenciesMap, s.runHistoryMap], + (queries, dependenciesMap, runHistoryMap): DataWarehouseSavedQuery[] => { + return queries.map((query) => ({ + ...query, + upstream_dependency_count: dependenciesMap[query.id]?.upstream_count, + downstream_dependency_count: dependenciesMap[query.id]?.downstream_count, + run_history: runHistoryMap[query.id], + })) + }, + ], + filteredViews: [ + (s) => [s.enrichedQueries, s.searchTerm], + (queries, searchTerm): DataWarehouseSavedQuery[] => { + const views = queries.filter((q) => !q.is_materialized) + if (!searchTerm) { + return views + } + return views.filter((v) => v.name.toLowerCase().includes(searchTerm.toLowerCase())) + }, + ], + filteredMaterializedViews: [ + (s) => [s.enrichedQueries, s.searchTerm], + (queries, searchTerm): DataWarehouseSavedQuery[] => { + const views = queries.filter((q) => q.is_materialized) + if (!searchTerm) { + return views + } + return views.filter((v) => v.name.toLowerCase().includes(searchTerm.toLowerCase())) + }, + ], + visibleMaterializedViews: [ + (s) => [s.filteredMaterializedViews, s.materializedViewsCurrentPage], + (views, currentPage): DataWarehouseSavedQuery[] => { + const startIndex = (currentPage - 1) * PAGE_SIZE + const endIndex = startIndex + PAGE_SIZE + return views.slice(startIndex, endIndex) + }, + ], + visibleViews: [ + (s) => [s.filteredViews, s.viewsCurrentPage], + (views, currentPage): DataWarehouseSavedQuery[] => { + const startIndex = (currentPage - 1) * PAGE_SIZE + const endIndex = startIndex + PAGE_SIZE + return views.slice(startIndex, endIndex) + }, + ], + }), + listeners(({ actions, values }) => ({ + deleteView: ({ viewId }) => { + LemonDialog.open({ + title: 'Delete view?', + description: 'Are you sure you want to delete this view? This action cannot be undone.', + primaryButton: { + children: 'Delete', + status: 'danger', + onClick: () => { + actions.deleteDataWarehouseSavedQuery(viewId) + lemonToast.success('View deleted successfully') + }, + }, + secondaryButton: { + children: 'Cancel', + }, + }) + }, + runMaterialization: ({ viewId }) => { + actions.runDataWarehouseSavedQuery(viewId) + }, + loadDataWarehouseSavedQueriesSuccess: () => { + // Load data for initially visible items + actions.loadVisibleData() + }, + setMaterializedViewsPage: () => { + // Load data when page changes + actions.loadVisibleData() + }, + setViewsPage: () => { + // Load data when page changes + actions.loadVisibleData() + }, + setSearchTerm: () => { + // Load data when search changes (pagination resets to page 1) + actions.loadVisibleData() + }, + loadVisibleData: () => { + // Get IDs of all currently visible items + const visibleMaterializedViewIds = values.visibleMaterializedViews.map((v) => v.id) + const visibleViewIds = values.visibleViews.map((v) => v.id) + const allVisibleIds = [...visibleMaterializedViewIds, ...visibleViewIds] + + // Load dependencies for all visible items (if not already loaded) + if (allVisibleIds.length > 0) { + actions.loadDependenciesForViews(allVisibleIds) + } + + // Load run history only for visible materialized views (if not already loaded) + if (visibleMaterializedViewIds.length > 0) { + actions.loadRunHistoryForViews(visibleMaterializedViewIds) + } + }, + })), + afterMount(({ actions, values }) => { + // If views are already loaded (e.g., from cache), load visible data immediately + if (values.dataWarehouseSavedQueries.length > 0) { + actions.loadVisibleData() + } + }), +]) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7cec905a50..b98567c07a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -4790,6 +4790,18 @@ export interface DataWarehouseTable { export type DataWarehouseTableTypes = 'CSV' | 'Parquet' | 'JSON' | 'CSVWithNames' +export type DataModelingJobStatus = 'Running' | 'Completed' | 'Failed' | 'Cancelled' + +export interface DataWarehouseSavedQueryRunHistory { + status: DataModelingJobStatus + timestamp?: string +} + +export interface DataWarehouseSavedQueryDependencies { + upstream_count: number + downstream_count: number +} + export interface DataWarehouseSavedQuery { /** UUID */ id: string @@ -4803,6 +4815,10 @@ export interface DataWarehouseSavedQuery { latest_error: string | null latest_history_id?: string is_materialized?: boolean + upstream_dependency_count?: number + downstream_dependency_count?: number + created_at?: string + run_history?: DataWarehouseSavedQueryRunHistory[] } export interface DataWarehouseSavedQueryDraft { diff --git a/products/data_warehouse/backend/api/saved_query.py b/products/data_warehouse/backend/api/saved_query.py index 8e78e8471e..77f5fc5d68 100644 --- a/products/data_warehouse/backend/api/saved_query.py +++ b/products/data_warehouse/backend/api/saved_query.py @@ -659,6 +659,57 @@ class DataWarehouseSavedQueryViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewS {"error": f"Failed to cancel workflow"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) + @action(methods=["GET"], detail=True) + def dependencies(self, request: request.Request, *args, **kwargs) -> response.Response: + """Return the count of immediate upstream and downstream dependencies for this saved query.""" + saved_query = self.get_object() + saved_query_id = saved_query.id.hex + + # Count immediate upstream (parents) - get unique parents from all paths to this node + upstream_paths = DataWarehouseModelPath.objects.filter( + team=saved_query.team, path__lquery=f"*.{saved_query_id}" + ) + upstream_ids: set[str] = set() + for path in upstream_paths: + if len(path.path) >= 2: + # Get the immediate parent (second to last in path) + parent_id = path.path[-2] + upstream_ids.add(parent_id) + + # Count immediate downstream (children) - get unique children that reference this node + downstream_paths = DataWarehouseModelPath.objects.filter( + team=saved_query.team, path__lquery=f"*.{saved_query_id}.*" + ) + downstream_ids: set[str] = set() + for path in downstream_paths: + # Find position of current view in path + try: + idx = path.path.index(saved_query_id) + if idx + 1 < len(path.path): + # Get immediate child (next node after current) + child_id = path.path[idx + 1] + downstream_ids.add(child_id) + except ValueError: + continue + + return response.Response({"upstream_count": len(upstream_ids), "downstream_count": len(downstream_ids)}) + + @action(methods=["GET"], detail=True) + def run_history(self, request: request.Request, *args, **kwargs) -> response.Response: + """Return the recent run history (up to 5 most recent) for this materialized view.""" + saved_query = self.get_object() + + # Get the 5 most recent runs + jobs = ( + DataModelingJob.objects.filter(saved_query=saved_query) + .order_by("-last_run_at")[:5] + .values("status", "last_run_at") + ) + + run_history = [{"status": job["status"], "timestamp": job["last_run_at"]} for job in jobs] + + return response.Response({"run_history": run_history}) + def try_convert_to_uuid(s: str) -> uuid.UUID | str: try: diff --git a/products/data_warehouse/backend/api/test/test_saved_query.py b/products/data_warehouse/backend/api/test/test_saved_query.py index 81a12f8e10..d0f14d1ba2 100644 --- a/products/data_warehouse/backend/api/test/test_saved_query.py +++ b/products/data_warehouse/backend/api/test/test_saved_query.py @@ -7,7 +7,12 @@ from unittest.mock import patch from posthog.models import ActivityLog -from products.data_warehouse.backend.models import DataWarehouseModelPath, DataWarehouseSavedQuery, DataWarehouseTable +from products.data_warehouse.backend.models import ( + DataModelingJob, + DataWarehouseModelPath, + DataWarehouseSavedQuery, + DataWarehouseTable, +) from products.data_warehouse.backend.models.datawarehouse_managed_viewset import DataWarehouseManagedViewSet from products.data_warehouse.backend.types import DataWarehouseManagedViewSetKind @@ -1118,3 +1123,227 @@ class TestSavedQuery(APIBaseTest): self.assertEqual( response.json()["detail"], "Cannot revert materialization of a query from a managed viewset." ) + + def test_dependencies_no_dependencies(self): + """Test dependencies endpoint returns zero counts for a view with no dependencies""" + response = self.client.post( + f"/api/environments/{self.team.id}/warehouse_saved_queries/", + { + "name": "simple_view", + "query": { + "kind": "HogQLQuery", + "query": "select event as event from events LIMIT 100", + }, + }, + ) + self.assertEqual(response.status_code, 201) + saved_query_id = response.json()["id"] + + # Test dependencies endpoint + response = self.client.get( + f"/api/environments/{self.team.id}/warehouse_saved_queries/{saved_query_id}/dependencies", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["upstream_count"], 1) # events + self.assertEqual(data["downstream_count"], 0) # No downstream dependencies + + def test_dependencies_with_upstream_and_downstream(self): + """Test dependencies endpoint correctly counts immediate dependencies""" + # Create parent view + response_parent = self.client.post( + f"/api/environments/{self.team.id}/warehouse_saved_queries/", + { + "name": "parent_view", + "query": { + "kind": "HogQLQuery", + "query": "select event as event from events LIMIT 100", + }, + }, + ) + self.assertEqual(response_parent.status_code, 201) + parent_id = response_parent.json()["id"] + + # Create child view that depends on parent + response_child = self.client.post( + f"/api/environments/{self.team.id}/warehouse_saved_queries/", + { + "name": "child_view", + "query": { + "kind": "HogQLQuery", + "query": f"select event as event from parent_view LIMIT 50", + }, + }, + ) + self.assertEqual(response_child.status_code, 201) + child_id = response_child.json()["id"] + + # Create grandchild view that depends on child + response_grandchild = self.client.post( + f"/api/environments/{self.team.id}/warehouse_saved_queries/", + { + "name": "grandchild_view", + "query": { + "kind": "HogQLQuery", + "query": f"select event as event from child_view LIMIT 25", + }, + }, + ) + self.assertEqual(response_grandchild.status_code, 201) + grandchild_id = response_grandchild.json()["id"] + + # Test parent dependencies (should have downstream but no upstream saved queries) + response = self.client.get( + f"/api/environments/{self.team.id}/warehouse_saved_queries/{parent_id}/dependencies", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["upstream_count"], 1) # events table + self.assertEqual(data["downstream_count"], 1) # child_view + + # Test child dependencies (should have both upstream and downstream) + response = self.client.get( + f"/api/environments/{self.team.id}/warehouse_saved_queries/{child_id}/dependencies", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["upstream_count"], 1) # parent_view (only immediate parent) + self.assertEqual(data["downstream_count"], 1) # grandchild_view + + # Test grandchild dependencies (should have upstream but no downstream) + response = self.client.get( + f"/api/environments/{self.team.id}/warehouse_saved_queries/{grandchild_id}/dependencies", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["upstream_count"], 1) # child_view (only immediate parent) + self.assertEqual(data["downstream_count"], 0) # No downstream + + def test_run_history_no_runs(self): + """Test run_history endpoint returns empty array for a view with no runs""" + response = self.client.post( + f"/api/environments/{self.team.id}/warehouse_saved_queries/", + { + "name": "view_no_runs", + "query": { + "kind": "HogQLQuery", + "query": "select event as event from events LIMIT 100", + }, + }, + ) + self.assertEqual(response.status_code, 201) + saved_query_id = response.json()["id"] + + # Test run_history endpoint + response = self.client.get( + f"/api/environments/{self.team.id}/warehouse_saved_queries/{saved_query_id}/run_history", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["run_history"], []) + + def test_run_history_with_runs(self): + """Test run_history endpoint returns correct run history""" + # Create a materialized view + response = self.client.post( + f"/api/environments/{self.team.id}/warehouse_saved_queries/", + { + "name": "materialized_view", + "query": { + "kind": "HogQLQuery", + "query": "select event as event from events LIMIT 100", + }, + }, + ) + self.assertEqual(response.status_code, 201) + saved_query_id = response.json()["id"] + saved_query = DataWarehouseSavedQuery.objects.get(id=saved_query_id) + + # Create multiple runs with different statuses + from django.utils import timezone + + now = timezone.now() + + # Create 7 runs to test the limit of 5 + runs = [] + for i in range(7): + status = DataModelingJob.Status.COMPLETED if i % 2 == 0 else DataModelingJob.Status.FAILED + run = DataModelingJob.objects.create( + team=self.team, + saved_query=saved_query, + status=status, + last_run_at=now - timedelta(hours=i), + ) + runs.append(run) + + # Test run_history endpoint + response = self.client.get( + f"/api/environments/{self.team.id}/warehouse_saved_queries/{saved_query_id}/run_history", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + + # Should return only the 5 most recent runs + self.assertEqual(len(data["run_history"]), 5) + + # Verify they are ordered by most recent first + for i in range(len(data["run_history"])): + expected_status = DataModelingJob.Status.COMPLETED if i % 2 == 0 else DataModelingJob.Status.FAILED + self.assertEqual(data["run_history"][i]["status"], expected_status) + self.assertIsNotNone(data["run_history"][i]["timestamp"]) + + # Verify the most recent run is first + most_recent_run = runs[0] + self.assertEqual(data["run_history"][0]["status"], most_recent_run.status) + + def test_run_history_mixed_statuses(self): + """Test run_history endpoint with various run statuses""" + response = self.client.post( + f"/api/environments/{self.team.id}/warehouse_saved_queries/", + { + "name": "mixed_status_view", + "query": { + "kind": "HogQLQuery", + "query": "select event as event from events LIMIT 100", + }, + }, + ) + self.assertEqual(response.status_code, 201) + saved_query_id = response.json()["id"] + saved_query = DataWarehouseSavedQuery.objects.get(id=saved_query_id) + + from django.utils import timezone + + now = timezone.now() + + # Create runs with different statuses + statuses = [ + DataModelingJob.Status.COMPLETED, + DataModelingJob.Status.FAILED, + DataModelingJob.Status.RUNNING, + DataModelingJob.Status.CANCELLED, + ] + + for i, status in enumerate(statuses): + DataModelingJob.objects.create( + team=self.team, + saved_query=saved_query, + status=status, + last_run_at=now - timedelta(hours=i), + ) + + # Test run_history endpoint + response = self.client.get( + f"/api/environments/{self.team.id}/warehouse_saved_queries/{saved_query_id}/run_history", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + + self.assertEqual(len(data["run_history"]), 4) + + # Verify all different statuses are present + returned_statuses = [run["status"] for run in data["run_history"]] + self.assertIn("Completed", returned_statuses) + self.assertIn("Failed", returned_statuses) + self.assertIn("Running", returned_statuses) + self.assertIn("Cancelled", returned_statuses)