Update ui

This commit is contained in:
soaxelbrooke 2025-07-12 12:20:43 -07:00
parent 7bb999ab47
commit 307f146e7c
5 changed files with 178 additions and 96 deletions

View file

@ -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<string, any>(),
logsExpanded: {} as Record<string, boolean>,
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')
])

View file

@ -46,13 +46,9 @@ export class DashboardService {
async getRecentActivity(): Promise<RecentActivitySummary> {
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: [],

View file

@ -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);
}
}
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);
}
};

View file

@ -178,11 +178,17 @@ pub async fn get_build_status(
})?;
// Convert events to summary format for response
let event_summaries: Vec<BuildEventSummary> = 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<BuildEventSummary> = 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<PartitionEventsRequest>,
) -> Result<Json<PartitionEventsResponse>, (StatusCode, Json<ErrorResponse>)> {
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<crate::build_event::EventType>) -> Strin
}
}
fn extract_navigation_data(event_type: &Option<crate::build_event::EventType>) -> (Option<String>, Option<String>, Option<String>) {
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;

View file

@ -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<String>, // For job events
pub partition_ref: Option<String>, // For partition events
pub delegated_build_id: Option<String>, // For delegation events
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]