diff --git a/databuild/dashboard/pages.ts b/databuild/dashboard/pages.ts index 073cd05..51b17ed 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 } from './utils'; +import { encodePartitionRef, BuildStatusBadge, PartitionStatusBadge, EventTypeBadge } from './utils'; // Page scaffold components export const RecentActivity = { @@ -10,20 +10,16 @@ export const RecentActivity = { pollInterval: null as NodeJS.Timeout | null, loadData() { - console.log('RecentActivity: Starting loadData, loading=', this.loading); this.loading = true; this.error = null; m.redraw(); // Redraw to show loading state const service = DashboardService.getInstance(); - console.log('RecentActivity: Got service instance, calling getRecentActivity'); return service.getRecentActivity() .then(data => { - console.log('RecentActivity: Got data successfully', data); this.data = data; this.loading = false; - console.log('RecentActivity: Data loaded, loading=', this.loading, 'data=', !!this.data); m.redraw(); // Explicitly redraw after data loads }) .catch(error => { @@ -52,7 +48,6 @@ export const RecentActivity = { }, view: function() { - console.log('RecentActivity: view() called, loading=', this.loading, 'data=', !!this.data, 'error=', this.error); if (this.loading && !this.data) { return m('div.container.mx-auto.p-4', [ @@ -207,12 +202,7 @@ export const RecentActivity = { }, build.id) ]), m('td', [ - m(`span.badge.badge-sm.${ - build.status === 'completed' ? 'badge-success' : - build.status === 'running' ? 'badge-warning' : - build.status === 'failed' ? 'badge-error' : - 'badge-neutral' - }`, build.status) + m(BuildStatusBadge, { status: build.status }) ]), m('td.text-sm.opacity-70', formatTime(build.createdAt)), ]) @@ -266,12 +256,7 @@ export const RecentActivity = { }, partition.ref) ]), m('td', [ - m(`span.badge.badge-sm.${ - partition.status === 'available' ? 'badge-success' : - partition.status === 'building' ? 'badge-warning' : - partition.status === 'failed' ? 'badge-error' : - 'badge-neutral' - }`, partition.status) + m(PartitionStatusBadge, { status: partition.status }) ]), m('td.text-sm.opacity-70', formatTime(partition.updatedAt)), ]) @@ -291,7 +276,6 @@ export const BuildStatus = { loading: true, error: null as string | null, partitionStatuses: new Map(), - logsExpanded: {} as Record, buildId: '', oninit(vnode: any) { @@ -310,8 +294,6 @@ export const BuildStatus = { this.error = null; m.redraw(); - console.log(`BuildStatus: Loading build ${this.buildId}`); - // Import types dynamically to avoid circular dependencies const { DefaultApi, Configuration } = await import('../client/typescript_generated/src/index'); const apiClient = new DefaultApi(new Configuration({ basePath: '' })); @@ -320,8 +302,6 @@ export const BuildStatus = { const buildResponse = await apiClient.apiV1BuildsBuildRequestIdGet({ buildRequestId: this.buildId }); this.data = buildResponse; - console.log('BuildStatus: Got build response:', buildResponse); - // Load partition statuses for all requested partitions if (buildResponse.requestedPartitions) { for (const partitionRef of buildResponse.requestedPartitions) { @@ -357,42 +337,42 @@ export const BuildStatus = { }, interval); }, - getStatusClass(status: string): string { - switch (status) { - case 'BuildRequestCompleted': return 'badge-success'; - case 'BuildRequestExecuting': return 'badge-warning'; - case 'BuildRequestPlanning': return 'badge-warning'; - case 'BuildRequestFailed': return 'badge-error'; - case 'BuildRequestCancelled': return 'badge-error'; - default: return 'badge-neutral'; + + getEventLink(event: any): { href: string; text: string } | null { + const eventType = event.eventType; + + switch (eventType) { + case 'job': + if (event.jobLabel) { + return { + href: `/jobs/${encodeURIComponent(event.jobLabel)}`, + text: 'Job Details' + }; + } + return null; + case 'partition': + if (event.partitionRef) { + return { + href: `/partitions/${encodePartitionRef(event.partitionRef)}`, + text: 'Partition Status' + }; + } + return null; + case 'delegation': + if (event.delegatedBuildId) { + return { + href: `/builds/${event.delegatedBuildId}`, + text: 'Delegated Build' + }; + } + return null; + case 'build_request': + // Self-referential, no additional link needed + return null; + default: + return null; } }, - - getPartitionStatusClass(status?: string): string { - switch (status) { - case 'PartitionAvailable': return 'badge-success'; - case 'PartitionBuilding': return 'badge-warning'; - case 'PartitionScheduled': return 'badge-warning'; - case 'PartitionFailed': return 'badge-error'; - case 'PartitionDelegated': return 'badge-info'; - default: return 'badge-neutral'; - } - }, - - isJobEvent(event: any): boolean { - return event.eventType?.includes('Job') || event.eventType?.includes('job'); - }, - - toggleLogs(eventId: string) { - this.logsExpanded[eventId] = !this.logsExpanded[eventId]; - m.redraw(); - }, - - formatJobLogs(event: any): string { - // For now, just return the message as formatted log - // In the future, this could parse structured log data - return event.message || ''; - }, view() { // Loading/error states similar to RecentActivity component @@ -438,7 +418,7 @@ export const BuildStatus = { m('.build-header.mb-6', [ m('h1.text-3xl.font-bold.mb-4', `Build ${this.buildId}`), m('.build-meta.flex.gap-4.items-center.mb-4', [ - m(`span.badge.badge-lg.${this.getStatusClass(this.data.status)}`, this.data.status), + m(BuildStatusBadge, { status: this.data.status, size: 'lg' }), m('.timestamp.text-sm.opacity-70', formatDateTime(new Date(this.data.createdAt / 1000000).toISOString())), m('.partitions.text-sm.opacity-70', `${this.data.requestedPartitions?.length || 0} partitions`), ]) @@ -454,8 +434,7 @@ export const BuildStatus = { return m('.partition-card.border.border-base-300.rounded.p-3', [ m('.partition-ref.font-mono.text-sm.break-all.mb-2', partitionRef), m('.flex.justify-between.items-center', [ - m(`span.badge.${this.getPartitionStatusClass(status?.status)}`, - status?.status || 'Unknown'), + m(PartitionStatusBadge, { status: status?.status || 'Unknown' }), status?.lastUpdated ? m('.updated-time.text-xs.opacity-60', formatDateTime(new Date(status.lastUpdated / 1000000).toISOString())) : null @@ -477,7 +456,7 @@ export const BuildStatus = { m('th', 'Timestamp'), m('th', 'Event Type'), m('th', 'Message'), - m('th', 'Actions') + m('th', 'Link') ]) ]), m('tbody', @@ -486,32 +465,24 @@ export const BuildStatus = { m('td.text-xs.font-mono', formatDateTime(new Date(event.timestamp / 1000000).toISOString())), m('td', [ - m('.badge.badge-sm.badge-outline', event.eventType) + m(EventTypeBadge, { eventType: event.eventType }) ]), m('td.text-sm', event.message || ''), m('td', [ - this.isJobEvent(event) ? - m('button.btn.btn-xs.btn-outline', { - onclick: () => this.toggleLogs(event.eventId) - }, this.logsExpanded[event.eventId] ? 'Hide' : 'Details') : null + (() => { + const link = this.getEventLink(event); + return link ? + m(m.route.Link, { + href: link.href, + class: 'link link-primary text-sm' + }, link.text) : + m('span.text-xs.opacity-50', '—'); + })() ]) ]) ) ) - ]), - // Expandable logs section - this.data.events.some((event: any) => this.logsExpanded[event.eventId]) ? - m('.mt-4', [ - m('h3.text-lg.font-semibold.mb-2', 'Event Details'), - this.data.events.filter((event: any) => this.logsExpanded[event.eventId]) - .map((event: any) => - m('.bg-base-200.rounded.p-3.mb-2', [ - m('.text-sm.font-semibold.mb-1', `${event.eventType} - ${event.eventId}`), - m('.text-xs.font-mono.whitespace-pre-wrap', - this.formatJobLogs(event)) - ]) - ) - ]) : null + ]) ]) : m('.text-center.py-8.text-base-content.opacity-60', 'No events') ]) diff --git a/databuild/dashboard/services.ts b/databuild/dashboard/services.ts index ff11d36..42d61d6 100644 --- a/databuild/dashboard/services.ts +++ b/databuild/dashboard/services.ts @@ -46,13 +46,9 @@ export class DashboardService { async getRecentActivity(): Promise { try { - console.log('DashboardService: Fetching real data from API...'); - // Use the new activity endpoint that aggregates all the data we need const activityResponse: ActivityResponse = await apiClient.apiV1ActivityGet(); - console.log('DashboardService: Got activity response:', activityResponse); - // Convert the API response to our dashboard format const recentBuilds: BuildRequest[] = activityResponse.recentBuilds.map((build: BuildSummary) => ({ id: build.buildRequestId, @@ -79,7 +75,6 @@ export class DashboardService { console.error('Failed to fetch recent activity:', error); // Fall back to mock data if API call fails - console.log('DashboardService: Falling back to mock data due to API error'); return { activeBuilds: 0, recentBuilds: [], diff --git a/databuild/dashboard/utils.ts b/databuild/dashboard/utils.ts index dca1f58..693fdbf 100644 --- a/databuild/dashboard/utils.ts +++ b/databuild/dashboard/utils.ts @@ -8,4 +8,82 @@ export function decodePartitionRef(encoded: string): string { const padding = '='.repeat((4 - (encoded.length % 4)) % 4); const padded = encoded.replace(/-/g, '+').replace(/_/g, '/') + padding; return atob(padded); -} \ No newline at end of file +} + +import m from 'mithril'; + +// Mithril components for status badges - encapsulates both logic and presentation + +export const BuildStatusBadge = { + view(vnode: any) { + const { status, size = 'sm', ...attrs } = vnode.attrs; + const normalizedStatus = status.toLowerCase(); + + let badgeClass = 'badge-neutral'; + if (normalizedStatus.includes('completed')) { + badgeClass = 'badge-success'; + } else if (normalizedStatus.includes('executing') || normalizedStatus.includes('planning')) { + badgeClass = 'badge-warning'; + } else if (normalizedStatus.includes('received')) { + badgeClass = 'badge-info'; + } else if (normalizedStatus.includes('failed') || normalizedStatus.includes('cancelled')) { + badgeClass = 'badge-error'; + } + + return m(`span.badge.badge-${size}.${badgeClass}`, attrs, status); + } +}; + +export const PartitionStatusBadge = { + view(vnode: any) { + const { status, size = 'sm', ...attrs } = vnode.attrs; + if (!status) { + return m(`span.badge.badge-${size}.badge-neutral`, attrs, 'Unknown'); + } + + const normalizedStatus = status.toLowerCase(); + let badgeClass = 'badge-neutral'; + + if (normalizedStatus.includes('available')) { + badgeClass = 'badge-success'; + } else if (normalizedStatus.includes('building') || normalizedStatus.includes('scheduled')) { + badgeClass = 'badge-warning'; + } else if (normalizedStatus.includes('requested') || normalizedStatus.includes('delegated')) { + badgeClass = 'badge-info'; + } else if (normalizedStatus.includes('failed')) { + badgeClass = 'badge-error'; + } + + return m(`span.badge.badge-${size}.${badgeClass}`, attrs, status); + } +}; + +export const EventTypeBadge = { + view(vnode: any) { + const { eventType, size = 'sm', ...attrs } = vnode.attrs; + + let badgeClass = 'badge-ghost'; + let displayName = eventType; + + switch (eventType) { + case 'build_request': + badgeClass = 'badge-primary'; + displayName = 'Build'; + break; + case 'job': + badgeClass = 'badge-secondary'; + displayName = 'Job'; + break; + case 'partition': + badgeClass = 'badge-accent'; + displayName = 'Partition'; + break; + case 'delegation': + badgeClass = 'badge-info'; + displayName = 'Delegation'; + break; + } + + return m(`span.badge.badge-${size}.${badgeClass}`, attrs, displayName); + } +}; \ No newline at end of file diff --git a/databuild/service/handlers.rs b/databuild/service/handlers.rs index 907dcc8..ffca2c0 100644 --- a/databuild/service/handlers.rs +++ b/databuild/service/handlers.rs @@ -178,11 +178,17 @@ pub async fn get_build_status( })?; // Convert events to summary format for response - let event_summaries: Vec = events.into_iter().map(|e| BuildEventSummary { - event_id: e.event_id, - timestamp: e.timestamp, - event_type: event_type_to_string(&e.event_type), - message: event_to_message(&e.event_type), + let event_summaries: Vec = events.into_iter().map(|e| { + let (job_label, partition_ref, delegated_build_id) = extract_navigation_data(&e.event_type); + BuildEventSummary { + event_id: e.event_id, + timestamp: e.timestamp, + event_type: event_type_to_string(&e.event_type), + message: event_to_message(&e.event_type), + job_label, + partition_ref, + delegated_build_id, + } }).collect(); let final_status_string = BuildGraphService::status_to_string(final_status); @@ -311,11 +317,17 @@ pub async fn get_partition_events( Path(request): Path, ) -> Result, (StatusCode, Json)> { let events = match service.event_log.get_partition_events(&request.ref_param, None).await { - Ok(events) => events.into_iter().map(|e| BuildEventSummary { - event_id: e.event_id, - timestamp: e.timestamp, - event_type: event_type_to_string(&e.event_type), - message: event_to_message(&e.event_type), + Ok(events) => events.into_iter().map(|e| { + let (job_label, partition_ref, delegated_build_id) = extract_navigation_data(&e.event_type); + BuildEventSummary { + event_id: e.event_id, + timestamp: e.timestamp, + event_type: event_type_to_string(&e.event_type), + message: event_to_message(&e.event_type), + job_label, + partition_ref, + delegated_build_id, + } }).collect(), Err(e) => { error!("Failed to get partition events: {}", e); @@ -583,6 +595,28 @@ fn event_to_message(event_type: &Option) -> Strin } } +fn extract_navigation_data(event_type: &Option) -> (Option, Option, Option) { + match event_type { + Some(crate::build_event::EventType::JobEvent(event)) => { + let job_label = event.job_label.as_ref().map(|l| l.label.clone()); + (job_label, None, None) + }, + Some(crate::build_event::EventType::PartitionEvent(event)) => { + let partition_ref = event.partition_ref.as_ref().map(|r| r.str.clone()); + (None, partition_ref, None) + }, + Some(crate::build_event::EventType::DelegationEvent(event)) => { + let delegated_build_id = Some(event.delegated_to_build_request_id.clone()); + (None, None, delegated_build_id) + }, + Some(crate::build_event::EventType::BuildRequestEvent(_)) => { + // Build request events don't need navigation links (self-referential) + (None, None, None) + }, + None => (None, None, None), + } +} + // New handlers for list endpoints use axum::extract::Query; use std::collections::HashMap; diff --git a/databuild/service/mod.rs b/databuild/service/mod.rs index be30475..7cbfdc1 100644 --- a/databuild/service/mod.rs +++ b/databuild/service/mod.rs @@ -63,6 +63,10 @@ pub struct BuildEventSummary { pub timestamp: i64, pub event_type: String, pub message: String, + // Navigation-relevant fields (populated based on event type) + pub job_label: Option, // For job events + pub partition_ref: Option, // For partition events + pub delegated_build_id: Option, // For delegation events } #[derive(Debug, Serialize, Deserialize, JsonSchema)]