diff --git a/databuild/dashboard/pages.ts b/databuild/dashboard/pages.ts index 1360e81..7fe1312 100644 --- a/databuild/dashboard/pages.ts +++ b/databuild/dashboard/pages.ts @@ -1,6 +1,6 @@ import m from 'mithril'; import { DashboardService, pollingManager, formatTime, formatDateTime, RecentActivitySummary } from './services'; -import { encodePartitionRef, BuildStatusBadge, PartitionStatusBadge, EventTypeBadge } from './utils'; +import { encodePartitionRef, decodePartitionRef, BuildStatusBadge, PartitionStatusBadge, EventTypeBadge } from './utils'; // Page scaffold components export const RecentActivity = { @@ -500,41 +500,476 @@ export const BuildStatus = { }; export const PartitionsList = { - view: () => m('div.container.mx-auto.p-4', [ - m('h1.text-3xl.font-bold.mb-4', 'Partitions'), - m('div.card.bg-base-100.shadow-xl', [ - m('div.card-body', [ - m('h2.card-title', 'Partition Listing'), - m('p', 'Searchable list of recently built partitions with build triggers.'), + data: null as any | null, + loading: true, + error: null as string | null, + searchTerm: '', + + async loadPartitions() { + try { + this.loading = true; + this.error = null; + m.redraw(); + + const { DefaultApi, Configuration } = await import('../client/typescript_generated/src/index'); + const apiClient = new DefaultApi(new Configuration({ basePath: '' })); + + const response = await apiClient.apiV1PartitionsGet(); + this.data = response; + this.loading = false; + m.redraw(); + } catch (error) { + console.error('Failed to load partitions:', error); + this.error = error instanceof Error ? error.message : 'Failed to load partitions'; + this.loading = false; + m.redraw(); + } + }, + + async buildPartition(partitionRef: string) { + try { + const { DefaultApi, Configuration } = await import('../client/typescript_generated/src/index'); + const apiClient = new DefaultApi(new Configuration({ basePath: '' })); + + const buildRequest = { + partitions: [partitionRef] + }; + + const response = await apiClient.apiV1BuildsPost({ buildRequest }); + + // Redirect to build status page + m.route.set(`/builds/${response.buildRequestId}`); + } catch (error) { + console.error('Failed to start build:', error); + alert(`Failed to start build: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }, + + filteredPartitions() { + if (!this.data?.partitions) return []; + + if (!this.searchTerm) return this.data.partitions; + + const search = this.searchTerm.toLowerCase(); + return this.data.partitions.filter((partition: any) => + partition.partitionRef.toLowerCase().includes(search) + ); + }, + + oninit() { + this.loadPartitions(); + }, + + view() { + if (this.loading && !this.data) { + return m('div.container.mx-auto.p-4', [ + m('div.flex.flex-col.justify-center.items-center.min-h-96', [ + m('span.loading.loading-spinner.loading-lg'), + m('span.ml-4.text-lg.mb-4', 'Loading partitions...'), + m('button.btn.btn-sm.btn-outline', { + onclick: () => this.loadPartitions() + }, 'Retry Load') + ]) + ]); + } + + if (this.error) { + return m('div.container.mx-auto.p-4', [ + m('div.alert.alert-error', [ + m('svg.stroke-current.shrink-0.h-6.w-6', { + fill: 'none', + viewBox: '0 0 24 24' + }, [ + m('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'stroke-width': '2', + d: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z' + }) + ]), + m('span', this.error), + m('div', [ + m('button.btn.btn-sm.btn-outline', { + onclick: () => this.loadPartitions() + }, 'Retry') + ]) + ]) + ]); + } + + const filteredPartitions = this.filteredPartitions(); + + return m('div.container.mx-auto.p-4', [ + m('.partitions-header.mb-6', [ + m('div.flex.justify-between.items-center.mb-4', [ + m('h1.text-3xl.font-bold', 'Partitions'), + m('.badge.badge-primary.badge-lg', `${this.data?.totalCount || 0} total`) + ]), + m('div.form-control.mb-4', [ - m('input.input.input-bordered[placeholder="Search partitions..."]'), - ]), - m('div.alert.alert-info', [ - m('span', 'Partition list will be populated from the service API.'), - ]), + m('input.input.input-bordered.w-full.max-w-md', { + placeholder: 'Search partitions...', + value: this.searchTerm, + oninput: (e: Event) => { + this.searchTerm = (e.target as HTMLInputElement).value; + m.redraw(); + } + }) + ]) ]), - ]), - ]) + + m('.partitions-content', [ + filteredPartitions.length === 0 ? + m('div.card.bg-base-100.shadow-xl', [ + m('div.card-body.text-center', [ + m('h2.card-title.justify-center', 'No Partitions Found'), + m('p.text-base-content.opacity-60', + this.searchTerm ? + 'No partitions match your search criteria.' : + 'No partitions have been built yet.') + ]) + ]) : + m('div.card.bg-base-100.shadow-xl', [ + m('div.card-body', [ + m('h2.card-title.mb-4', `Showing ${filteredPartitions.length} partitions`), + m('div.overflow-x-auto', [ + m('table.table.table-sm', [ + m('thead', [ + m('tr', [ + m('th', 'Partition Reference'), + m('th', 'Status'), + m('th', 'Last Updated'), + m('th', 'Actions'), + ]) + ]), + m('tbody', + filteredPartitions.map((partition: any) => + m('tr.hover', [ + m('td', [ + m('a.link.link-primary.font-mono.text-sm.break-all', { + href: `/partitions/${encodePartitionRef(partition.partitionRef)}`, + onclick: (e: Event) => { + e.preventDefault(); + m.route.set(`/partitions/${encodePartitionRef(partition.partitionRef)}`); + }, + title: partition.partitionRef + }, partition.partitionRef) + ]), + m('td', [ + m(PartitionStatusBadge, { status: partition.status }) + ]), + m('td.text-sm.opacity-70', formatTime(new Date(partition.updatedAt / 1000000).toISOString())), + m('td', [ + m('button.btn.btn-sm.btn-primary', { + onclick: () => this.buildPartition(partition.partitionRef) + }, 'Build'), + partition.buildRequestId ? + m('a.btn.btn-sm.btn-outline.ml-2', { + href: `/builds/${partition.buildRequestId}`, + onclick: (e: Event) => { + e.preventDefault(); + m.route.set(`/builds/${partition.buildRequestId}`); + } + }, 'View Build') : null + ]) + ]) + ) + ) + ]) + ]) + ]) + ]) + ]) + ]); + } }; export const PartitionStatus = { - view: (vnode: any) => { - const encodedRef = vnode.attrs.base64_ref; + data: null as any | null, + events: null as any | null, + loading: true, + error: null as string | null, + partitionRef: '', + buildHistory: [] as any[], + + async loadPartition() { + try { + this.loading = true; + this.error = null; + m.redraw(); + + const { DefaultApi, Configuration } = await import('../client/typescript_generated/src/index'); + const apiClient = new DefaultApi(new Configuration({ basePath: '' })); + + // Load partition status + const statusResponse = await apiClient.apiV1PartitionsRefStatusGet({ + ref: this.partitionRef + }); + this.data = statusResponse; + + // Load partition events for build history + const eventsResponse = await apiClient.apiV1PartitionsRefEventsGet({ + ref: this.partitionRef + }); + this.events = eventsResponse; + + // Create build history from events + this.buildHistory = this.extractBuildHistory(eventsResponse.events); + + this.loading = false; + m.redraw(); + } catch (error) { + console.error('Failed to load partition:', error); + this.error = error instanceof Error ? error.message : 'Failed to load partition'; + this.loading = false; + m.redraw(); + } + }, + + extractBuildHistory(events: any[]): any[] { + // Group events by build request ID to create build history entries + const buildRequests = new Map(); + + events.forEach(event => { + if (event.buildRequestId) { + if (!buildRequests.has(event.buildRequestId)) { + buildRequests.set(event.buildRequestId, { + id: event.buildRequestId, + status: 'Unknown', + startedAt: event.timestamp, + completedAt: null, + events: [] + }); + } + + const build = buildRequests.get(event.buildRequestId); + build.events.push(event); + + // Update status based on event type + if (event.eventType === 'build_request') { + if (event.message?.includes('completed') || event.message?.includes('successful')) { + build.status = 'Completed'; + build.completedAt = event.timestamp; + } else if (event.message?.includes('failed') || event.message?.includes('error')) { + build.status = 'Failed'; + build.completedAt = event.timestamp; + } else if (event.message?.includes('executing') || event.message?.includes('running')) { + build.status = 'Executing'; + } else if (event.message?.includes('planning')) { + build.status = 'Planning'; + } + } + } + }); + + // Convert to array and sort by start time (newest first) + return Array.from(buildRequests.values()).sort((a, b) => b.startedAt - a.startedAt); + }, + + async buildPartition(forceRebuild: boolean = false) { + try { + const { DefaultApi, Configuration } = await import('../client/typescript_generated/src/index'); + const apiClient = new DefaultApi(new Configuration({ basePath: '' })); + + const buildRequest = { + partitions: [this.partitionRef] + }; + + const response = await apiClient.apiV1BuildsPost({ buildRequest }); + + // Redirect to build status page + m.route.set(`/builds/${response.buildRequestId}`); + } catch (error) { + console.error('Failed to start build:', error); + alert(`Failed to start build: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }, + + oninit(vnode: any) { + this.partitionRef = decodePartitionRef(vnode.attrs.base64_ref); + this.loadPartition(); + }, + + view() { + if (this.loading && !this.data) { + return m('div.container.mx-auto.p-4', [ + m('div.flex.flex-col.justify-center.items-center.min-h-96', [ + m('span.loading.loading-spinner.loading-lg'), + m('span.ml-4.text-lg.mb-4', 'Loading partition...'), + m('button.btn.btn-sm.btn-outline', { + onclick: () => this.loadPartition() + }, 'Retry Load') + ]) + ]); + } + + if (this.error) { + return m('div.container.mx-auto.p-4', [ + m('div.alert.alert-error', [ + m('svg.stroke-current.shrink-0.h-6.w-6', { + fill: 'none', + viewBox: '0 0 24 24' + }, [ + m('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + 'stroke-width': '2', + d: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z' + }) + ]), + m('span', this.error), + m('div', [ + m('button.btn.btn-sm.btn-outline', { + onclick: () => this.loadPartition() + }, 'Retry') + ]) + ]) + ]); + } + + if (!this.data) return m('div'); + return m('div.container.mx-auto.p-4', [ - m('h1.text-3xl.font-bold.mb-4', 'Partition Status'), - m('div.card.bg-base-100.shadow-xl', [ - m('div.card-body', [ - m('h2.card-title', 'Partition Details'), - m('p', 'Partition lifecycle, build history, and related information.'), - m('div.alert.alert-info', [ - m('span', `Encoded reference: ${encodedRef}`), - ]), - m('div.card-actions.justify-end', [ - m('button.btn.btn-primary', 'Build Now'), - m('button.btn.btn-secondary', 'Force Rebuild'), + // Partition Header + m('.partition-header.mb-6', [ + m('div.flex.justify-between.items-start.mb-4', [ + m('div.flex-1', [ + m('h1.text-3xl.font-bold.mb-2', 'Partition Status'), + m('div.font-mono.text-lg.break-all.bg-base-200.p-3.rounded', this.partitionRef) ]), + m('div.flex.flex-col.gap-2', [ + m('button.btn.btn-primary', { + onclick: () => this.buildPartition(false) + }, 'Build Now'), + m('button.btn.btn-secondary', { + onclick: () => this.buildPartition(true) + }, 'Force Rebuild'), + ]) ]), + + m('div.partition-meta.flex.gap-4.items-center.mb-4', [ + m(PartitionStatusBadge, { status: this.data?.status || 'Unknown', size: 'lg' }), + this.data?.lastUpdated ? + m('.timestamp.text-sm.opacity-70', + `Last updated: ${formatDateTime(new Date(this.data.lastUpdated / 1000000).toISOString())}`) : null, + ]) ]), + + // Main Content + m('.partition-content.space-y-6', [ + // Build History + m('.build-history.card.bg-base-100.shadow-xl', [ + m('.card-body', [ + m('h2.card-title.text-xl.mb-4', `Build History (${this.buildHistory?.length || 0} builds)`), + !this.buildHistory || this.buildHistory.length === 0 ? + m('.text-center.py-8.text-base-content.opacity-60', 'No build history available') : + m('.overflow-x-auto', [ + m('table.table.table-sm', [ + m('thead', [ + m('tr', [ + m('th', 'Build Request'), + m('th', 'Status'), + m('th', 'Started'), + m('th', 'Completed'), + m('th', 'Events'), + ]) + ]), + m('tbody', + this.buildHistory.map((build: any) => + m('tr.hover', [ + m('td', [ + m('a.link.link-primary.font-mono.text-sm', { + href: `/builds/${build.id}`, + onclick: (e: Event) => { + e.preventDefault(); + m.route.set(`/builds/${build.id}`); + } + }, build.id) + ]), + m('td', [ + m(BuildStatusBadge, { status: build.status }) + ]), + m('td.text-sm.opacity-70', + formatDateTime(new Date(build.startedAt / 1000000).toISOString())), + m('td.text-sm.opacity-70', + build.completedAt ? + formatDateTime(new Date(build.completedAt / 1000000).toISOString()) : + '—'), + m('td.text-sm.opacity-70', `${build.events?.length || 0} events`) + ]) + ) + ) + ]) + ]) + ]) + ]), + + // Related Build Requests + this.data?.buildRequests && this.data.buildRequests.length > 0 ? + m('.related-builds.card.bg-base-100.shadow-xl', [ + m('.card-body', [ + m('h2.card-title.text-xl.mb-4', 'Related Build Requests'), + m('.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-3', + this.data.buildRequests.map((buildId: string) => + m('.build-card.border.border-base-300.rounded.p-3', [ + m('a.link.link-primary.font-mono.text-sm', { + href: `/builds/${buildId}`, + onclick: (e: Event) => { + e.preventDefault(); + m.route.set(`/builds/${buildId}`); + } + }, buildId) + ]) + ) + ) + ]) + ]) : null, + + // Raw Events + this.events?.events && this.events.events.length > 0 ? + m('.partition-events.card.bg-base-100.shadow-xl', [ + m('.card-body', [ + m('h2.card-title.text-xl.mb-4', `All Events (${this.events.events.length})`), + m('.overflow-x-auto', [ + m('table.table.table-xs', [ + m('thead', [ + m('tr', [ + m('th', 'Timestamp'), + m('th', 'Event Type'), + m('th', 'Build Request'), + m('th', 'Message'), + ]) + ]), + m('tbody', + this.events.events.slice(0, 20).map((event: any) => // Show first 20 events + m('tr.hover', [ + m('td.text-xs.font-mono', + formatDateTime(new Date(event.timestamp / 1000000).toISOString())), + m('td', [ + m(EventTypeBadge, { eventType: event.eventType, size: 'xs' }) + ]), + m('td', + event.buildRequestId ? + m('a.link.link-primary.font-mono.text-xs', { + href: `/builds/${event.buildRequestId}`, + onclick: (e: Event) => { + e.preventDefault(); + m.route.set(`/builds/${event.buildRequestId}`); + } + }, event.buildRequestId) : '—'), + m('td.text-xs', event.message || ''), + ]) + ) + ) + ]) + ]), + this.events.events.length > 20 ? + m('.text-center.mt-4', [ + m('.text-sm.opacity-60', `Showing first 20 of ${this.events.events.length} events`) + ]) : null + ]) + ]) : null + ]) ]); } }; diff --git a/databuild/event_log/sqlite.rs b/databuild/event_log/sqlite.rs index 88d13c4..6f33ec5 100644 --- a/databuild/event_log/sqlite.rs +++ b/databuild/event_log/sqlite.rs @@ -290,15 +290,56 @@ impl BuildEventLog for SqliteBuildEventLog { async fn get_partition_events( &self, - _partition_ref: &str, - _since: Option + partition_ref: &str, + since: Option ) -> Result> { - // This method is not implemented because it would require complex joins - // to reconstruct complete event data. Use get_build_request_events instead - // which properly reconstructs all event types for a build request. - Err(BuildEventLogError::QueryError( - "get_partition_events is not implemented - use get_build_request_events to get complete event data".to_string() - )) + // First get the build request IDs (release the connection lock quickly) + let build_ids: Vec = { + let conn = self.connection.lock().unwrap(); + + // Get all events for builds that included this partition + // First find all build request IDs that have events for this partition + let build_ids_query = if since.is_some() { + "SELECT DISTINCT be.build_request_id + FROM build_events be + JOIN partition_events pe ON be.event_id = pe.event_id + WHERE pe.partition_ref = ? AND be.timestamp > ?" + } else { + "SELECT DISTINCT be.build_request_id + FROM build_events be + JOIN partition_events pe ON be.event_id = pe.event_id + WHERE pe.partition_ref = ?" + }; + + let mut stmt = conn.prepare(build_ids_query) + .map_err(|e| BuildEventLogError::QueryError(e.to_string()))?; + + let row_mapper = |row: &Row| -> rusqlite::Result { + Ok(row.get::<_, String>(0)?) + }; + + let build_ids_result: Vec = if let Some(since_timestamp) = since { + stmt.query_map(params![partition_ref, since_timestamp], row_mapper) + } else { + stmt.query_map(params![partition_ref], row_mapper) + }.map_err(|e| BuildEventLogError::QueryError(e.to_string()))? + .collect::, _>>() + .map_err(|e| BuildEventLogError::QueryError(e.to_string()))?; + + build_ids_result + }; // Connection lock is released here + + // Now get all events for those build requests (this gives us complete event reconstruction) + let mut all_events = Vec::new(); + for build_id in build_ids { + let events = self.get_build_request_events(&build_id, since).await?; + all_events.extend(events); + } + + // Sort events by timestamp + all_events.sort_by_key(|e| e.timestamp); + + Ok(all_events) } async fn get_job_run_events( diff --git a/databuild/service/handlers.rs b/databuild/service/handlers.rs index 70d1892..4e2df8b 100644 --- a/databuild/service/handlers.rs +++ b/databuild/service/handlers.rs @@ -185,6 +185,7 @@ pub async fn get_build_status( timestamp: e.timestamp, event_type: event_type_to_string(&e.event_type), message: event_to_message(&e.event_type), + build_request_id: e.build_request_id, job_label, partition_ref, delegated_build_id, @@ -324,6 +325,7 @@ pub async fn get_partition_events( timestamp: e.timestamp, event_type: event_type_to_string(&e.event_type), message: event_to_message(&e.event_type), + build_request_id: e.build_request_id, job_label, partition_ref, delegated_build_id, diff --git a/databuild/service/mod.rs b/databuild/service/mod.rs index 9eace51..aba31ac 100644 --- a/databuild/service/mod.rs +++ b/databuild/service/mod.rs @@ -63,6 +63,7 @@ pub struct BuildEventSummary { pub timestamp: i64, pub event_type: String, pub message: String, + pub build_request_id: String, // Build request ID for navigation // Navigation-relevant fields (populated based on event type) pub job_label: Option, // For job events pub partition_ref: Option, // For partition events