Add mermaid diagram and build timeline

This commit is contained in:
Stuart Axelbrooke 2025-08-04 12:44:15 -07:00
parent 73bea35d4c
commit 475b9433ec
2 changed files with 142 additions and 25 deletions

View file

@ -311,6 +311,9 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
error: null as string | null,
partitionStatuses: new Map<string, DashboardPartition>(),
buildId: '',
mermaidDiagram: null as string | null,
mermaidLoading: false,
mermaidError: null as string | null,
oninit(vnode: m.Vnode<BuildStatusAttrs>) {
this.buildId = vnode.attrs.id;
@ -351,6 +354,11 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
}
}
// Load mermaid diagram if we don't have it yet
if (!this.mermaidDiagram && !this.mermaidLoading) {
this.loadMermaidDiagram();
}
this.loading = false;
m.redraw();
} catch (error) {
@ -361,6 +369,37 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
}
},
async loadMermaidDiagram() {
try {
this.mermaidLoading = true;
this.mermaidError = null;
m.redraw();
const service = DashboardService.getInstance();
const diagram = await service.getMermaidDiagram(this.buildId);
if (diagram) {
this.mermaidDiagram = diagram;
// Trigger mermaid to render the diagram after the DOM updates
setTimeout(() => {
if (typeof window !== 'undefined' && (window as any).mermaid) {
(window as any).mermaid.init();
}
}, 100);
} else {
this.mermaidError = 'No job graph available for this build';
}
this.mermaidLoading = false;
m.redraw();
} catch (error) {
console.error('Failed to load mermaid diagram:', error);
this.mermaidError = error instanceof Error ? error.message : 'Failed to load diagram';
this.mermaidLoading = false;
m.redraw();
}
},
startPolling() {
// Use different poll intervals based on build status
const isActive = this.data?.status_name === 'EXECUTING' ||
@ -414,6 +453,15 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
const build = this.data;
const timelineData = [
{stage: 'Build Requested', time: build.requested_at, icon: '🕚'},
...(build.started_at ? [{stage: 'Build Started', time: build.started_at, icon: '▶️'}] : []),
// ...(this.data.events as BuildEvent[]).filter(ev => ev.job_event !== null).map((ev) => ({
// stage: ev.job_event.status_name, time: ev.timestamp, icon: '🙃'
// })),
...(build.completed_at ? [{stage: 'Build Completed', time: build.completed_at, icon: '✅'}] : []),
];
return m('div.container.mx-auto.p-4', [
// Build Header
m('.build-header.mb-6', [
@ -432,12 +480,12 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
]),
m('.stat.bg-base-100.shadow.rounded-lg.p-4', [
m('.stat-title', 'Jobs'),
m('.stat-value.text-2xl', `${build.completed_jobs}/${build.total_jobs}`),
m('.stat-value.text-2xl', `${build.completed_jobs}`),
m('.stat-desc', 'completed')
]),
m('.stat.bg-base-100.shadow.rounded-lg.p-4', [
m('.stat-title', 'Duration'),
m('.stat-value.text-2xl', build.duration_ms ? formatDuration(build.duration_ms) : '—'),
m('.stat-value.text-2xl', (build.completed_at - build.started_at) ? formatDuration((build.completed_at - build.started_at)) : '—'),
m('.stat-desc', build.started_at ? formatDateTime(build.started_at) : 'Not started')
])
])
@ -504,31 +552,74 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
m('.metric-label.text-sm.opacity-60', 'Total Jobs')
])
]),
m('.build-timeline.mt-6', [
m('.timeline.text-sm', [
m('.timeline-item', [
m('.timeline-marker.text-primary', '●'),
m('.timeline-content', [
m('.font-medium', 'Requested'),
m('.opacity-60', formatDateTime(build.requested_at))
m('ul.timeline.mx-auto',timelineData.map((item) => {
return m('li', [
...(item.stage === 'Build Requested' ? [] : [m("hr")]),
m('div.font-medium.timeline-middle', item.icon),
m("div.timeline-box.timeline-end", {
style: {
'--timeline-color': item.stage === 'Requested' ? '#1976d2' : '#6b7280'
}
}, [
m('span.flex.flex-col.gap-2', {
class: 'timeline-point',
}, [
m('.font-medium', item.stage),
m('.text-xs.opacity-60', formatDateTime(item.time)),
])
]
),
...(item.stage === 'Build Completed' ? [] : [m("hr")]),
]
)
})),
])
]),
build.started_at && m('.timeline-item', [
m('.timeline-marker.text-info', '●'),
m('.timeline-content', [
m('.font-medium', 'Started'),
m('.opacity-60', formatDateTime(build.started_at))
])
// Build Graph Visualization
m('.build-graph.card.bg-base-100.shadow-xl', [
m('.card-body', [
m('.flex.justify-between.items-center.mb-4', [
m('h2.card-title.text-xl', 'Build Graph'),
this.mermaidDiagram && m('button.btn.btn-sm.btn-outline', {
onclick: () => this.loadMermaidDiagram()
}, 'Refresh Diagram')
]),
build.completed_at && m('.timeline-item', [
m('.timeline-marker.text-success', '●'),
m('.timeline-content', [
m('.font-medium', 'Completed'),
m('.opacity-60', formatDateTime(build.completed_at))
])
])
].filter(Boolean))
this.mermaidLoading ?
m('.flex.justify-center.items-center.h-32', [
m('span.loading.loading-spinner.loading-lg'),
m('span.ml-4', 'Loading diagram...')
]) :
this.mermaidError ?
m('.alert.alert-warning', [
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': 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z'
})
]),
m('span', this.mermaidError),
m('div', [
m('button.btn.btn-sm.btn-outline', {
onclick: () => this.loadMermaidDiagram()
}, 'Retry')
])
]) :
this.mermaidDiagram ?
m('.mermaid-container.w-full.overflow-x-auto', [
m('pre.mermaid.text-center', {
key: `mermaid-${this.buildId}` // Force re-render when buildId changes
}, this.mermaidDiagram)
]) :
m('.text-center.py-8.text-base-content.opacity-60', 'No graph visualization available')
])
])
])

View file

@ -320,6 +320,32 @@ export class DashboardService {
return null;
}
}
async getMermaidDiagram(buildId: string): Promise<string | null> {
try {
const url = `/api/v1/builds/${buildId}/mermaid`;
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
return null; // Build not found or no job graph
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Validate response structure
if (typeof data === 'object' && data !== null && 'mermaid' in data && typeof data.mermaid === 'string') {
return data.mermaid;
}
throw new Error('Invalid mermaid response structure');
} catch (error) {
console.error('Failed to fetch mermaid diagram:', error);
return null;
}
}
}
// Polling manager with Page Visibility API integration