databuild/databuild/dashboard/pages.ts
Stuart Axelbrooke 24482e2cc4
Some checks are pending
/ setup (push) Waiting to run
Big compile time correctness commit
2025-07-21 19:22:51 -07:00

1346 lines
No EOL
50 KiB
TypeScript

import m from 'mithril';
import { DashboardService, pollingManager, formatTime, formatDateTime, formatDuration, formatDate } from './services';
import { encodePartitionRef, decodePartitionRef, encodeJobLabel, decodeJobLabel, BuildStatusBadge, PartitionStatusBadge, EventTypeBadge } from './utils';
import {
TypedComponent,
RecentActivityAttrs,
BuildStatusAttrs,
PartitionStatusAttrs,
PartitionsListAttrs,
JobsListAttrs,
JobMetricsAttrs,
GraphAnalysisAttrs,
DashboardActivity,
DashboardBuild,
DashboardPartition,
DashboardJob,
getTypedRouteParams
} from './types';
import {
PartitionRef
} from '../client/typescript_generated/src/index';
// Page scaffold components
export const RecentActivity: TypedComponent<RecentActivityAttrs> = {
data: null as DashboardActivity | null,
loading: true,
error: null as string | null,
pollInterval: null as NodeJS.Timeout | null,
loadData() {
this.loading = true;
this.error = null;
m.redraw(); // Redraw to show loading state
const service = DashboardService.getInstance();
return service.getRecentActivity()
.then(data => {
this.data = data;
this.loading = false;
m.redraw(); // Explicitly redraw after data loads
})
.catch(error => {
console.error('RecentActivity: Error in loadData:', error);
this.error = error instanceof Error ? error.message : 'Failed to load data';
this.loading = false;
m.redraw(); // Redraw after error
});
},
oninit(vnode: m.Vnode<RecentActivityAttrs>) {
// Load initial data - Mithril will automatically redraw after promise resolves
this.loadData();
// Set up polling for real-time updates (5 second interval)
if (pollingManager.isVisible()) {
pollingManager.startPolling('recent-activity', () => {
this.loadData();
}, 5000);
}
},
onremove(vnode: m.VnodeDOM<RecentActivityAttrs>) {
// Clean up polling when component is removed
pollingManager.stopPolling('recent-activity');
},
view: function(vnode: m.Vnode<RecentActivityAttrs>) {
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 dashboard...'),
m('button.btn.btn-sm.btn-outline', {
onclick: () => this.loadData()
}, '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.loadData()
}, 'Retry')
])
])
]);
}
const data = this.data;
if (!data) return m('div');
return m('div.container.mx-auto.p-4', [
// Dashboard Header
m('div.dashboard-header.mb-6', [
m('div.flex.justify-between.items-center.mb-4', [
m('h1.text-3xl.font-bold', 'DataBuild Dashboard'),
m('div.badge.badge-primary.badge-lg', data.graph_name)
]),
// Statistics - Updated to use DashboardActivity field names
m('div.stats.shadow.w-full.bg-base-100', [
m('div.stat', [
m('div.stat-figure.text-primary', [
m('svg.w-8.h-8', {
fill: 'none',
stroke: 'currentColor',
viewBox: '0 0 24 24'
}, [
m('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M13 10V3L4 14h7v7l9-11h-7z'
})
])
]),
m('div.stat-title', 'Active Builds'),
m('div.stat-value.text-primary', data.active_builds_count),
m('div.stat-desc', 'Currently running')
]),
m('div.stat', [
m('div.stat-figure.text-secondary', [
m('svg.w-8.h-8', {
fill: 'none',
stroke: 'currentColor',
viewBox: '0 0 24 24'
}, [
m('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z'
})
])
]),
m('div.stat-title', 'Recent Builds'),
m('div.stat-value.text-secondary', data.recent_builds.length),
m('div.stat-desc', 'In the last hour')
]),
m('div.stat', [
m('div.stat-figure.text-accent', [
m('svg.w-8.h-8', {
fill: 'none',
stroke: 'currentColor',
viewBox: '0 0 24 24'
}, [
m('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M9 5l8 4'
})
])
]),
m('div.stat-title', 'Total Partitions'),
m('div.stat-value.text-accent', data.total_partitions_count),
m('div.stat-desc', 'Managed partitions')
])
])
]),
// Dashboard Content Grid
m('div.dashboard-content.grid.grid-cols-1.lg:grid-cols-2.gap-6', [
// Recent Build Requests
m('div.recent-builds.card.bg-base-100.shadow-xl', [
m('div.card-body', [
m('h2.card-title.text-xl.mb-4', [
m('svg.w-6.h-6.mr-2', {
fill: 'none',
stroke: 'currentColor',
viewBox: '0 0 24 24'
}, [
m('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M13 10V3L4 14h7v7l9-11h-7z'
})
]),
'Recent Build Requests'
]),
data.recent_builds.length === 0
? m('div.text-center.py-8.text-base-content.opacity-60', 'No recent builds')
: m('div.overflow-x-auto', [
m('table.table.table-sm', [
m('thead', [
m('tr', [
m('th', 'Build ID'),
m('th', 'Status'),
m('th', 'Created'),
])
]),
m('tbody',
data.recent_builds.map((build: DashboardBuild) =>
m('tr.hover', [
m('td', [
m('a.link.link-primary.font-mono.text-sm', {
href: `/builds/${build.build_request_id}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/builds/${build.build_request_id}`);
}
}, build.build_request_id)
]),
m('td', [
// KEY FIX: build.status_name is now always a string, prevents runtime errors
m(BuildStatusBadge, { status: build.status_name })
]),
m('td.text-sm.opacity-70', formatTime(build.requested_at)),
])
)
)
])
])
])
]),
// Recent Partition Builds
m('div.recent-partitions.card.bg-base-100.shadow-xl', [
m('div.card-body', [
m('h2.card-title.text-xl.mb-4', [
m('svg.w-6.h-6.mr-2', {
fill: 'none',
stroke: 'currentColor',
viewBox: '0 0 24 24'
}, [
m('path', {
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'stroke-width': '2',
d: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M9 5l8 4'
})
]),
'Recent Partition Builds'
]),
data.recent_partitions.length === 0
? m('div.text-center.py-8.text-base-content.opacity-60', 'No recent partitions')
: m('div.overflow-x-auto', [
m('table.table.table-sm', [
m('thead', [
m('tr', [
m('th', 'Partition Reference'),
m('th', 'Status'),
m('th', 'Updated'),
])
]),
m('tbody',
data.recent_partitions.map((partition: DashboardPartition) =>
m('tr.hover', [
m('td', [
m('a.link.link-primary.font-mono.text-sm.break-all', {
// KEY FIX: partition.partition_ref.str is now always a string, not an object
href: `/partitions/${encodePartitionRef(partition.partition_ref.str)}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/partitions/${encodePartitionRef(partition.partition_ref.str)}`);
},
title: partition.partition_ref.str
}, partition.partition_ref.str)
]),
m('td', [
// KEY FIX: partition.status_name is now always a string, prevents runtime errors
m(PartitionStatusBadge, { status: partition.status_name })
]),
m('td.text-sm.opacity-70',
// KEY FIX: Proper null handling for last_updated
partition.last_updated ? formatTime(partition.last_updated) : '—'),
])
)
)
])
])
])
])
])
]);
}
};
/*
// OLD BUILDSTATUS COMPONENT - COMMENTED OUT FOR CLEAN REBUILD
// This component had mixed old/new patterns and complex direct API calls
// Rebuilding with proper dashboard types architecture
export const BuildStatus_OLD: TypedComponent<BuildStatusAttrs> = {
// ... (full old implementation preserved for reference)
};
*/
// CLEAN REBUILD: BuildStatus using proper dashboard architecture
export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
data: null as DashboardBuild | null,
loading: true,
error: null as string | null,
partitionStatuses: new Map<string, DashboardPartition>(),
buildId: '',
oninit(vnode: m.Vnode<BuildStatusAttrs>) {
this.buildId = vnode.attrs.id;
this.loadBuild();
this.startPolling();
},
onremove(vnode: m.VnodeDOM<BuildStatusAttrs>) {
pollingManager.stopPolling(`build-status-${this.buildId}`);
},
async loadBuild() {
try {
this.loading = true;
this.error = null;
m.redraw();
const service = DashboardService.getInstance();
// Get build details using our transformation layer
const buildData = await service.getBuildDetail(this.buildId);
if (!buildData) {
throw new Error(`Build ${this.buildId} not found`);
}
this.data = buildData;
// Load partition statuses using our transformation layer
this.partitionStatuses.clear();
for (const partitionRef of buildData.requested_partitions) {
try {
const partitionData = await service.getPartitionDetail(partitionRef.str);
if (partitionData) {
this.partitionStatuses.set(partitionRef.str, partitionData);
}
} catch (e) {
console.warn(`Failed to load status for partition ${partitionRef.str}:`, e);
}
}
this.loading = false;
m.redraw();
} catch (error) {
console.error('Failed to load build:', error);
this.error = error instanceof Error ? error.message : 'Failed to load build';
this.loading = false;
m.redraw();
}
},
startPolling() {
// Use different poll intervals based on build status
const isActive = this.data?.status_name === 'EXECUTING' ||
this.data?.status_name === 'PLANNING';
const interval = isActive ? 2000 : 10000; // 2s for active, 10s for completed
pollingManager.startPolling(`build-status-${this.buildId}`, () => {
this.loadBuild();
}, interval);
},
view(vnode: m.Vnode<BuildStatusAttrs>) {
// Loading/error states
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 build status...'),
m('button.btn.btn-sm.btn-outline', {
onclick: () => this.loadBuild()
}, '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.loadBuild()
}, 'Retry')
])
])
]);
}
if (!this.data) return m('div');
const build = this.data;
return m('div.container.mx-auto.p-4', [
// Build Header
m('.build-header.mb-6', [
m('h1.text-3xl.font-bold.mb-4', `Build ${this.buildId}`),
m('.build-meta.grid.grid-cols-1.md:grid-cols-4.gap-4.mb-6', [
m('.stat.bg-base-100.shadow.rounded-lg.p-4', [
m('.stat-title', 'Status'),
m('.stat-value.text-2xl', [
m(BuildStatusBadge, { status: build.status_name, size: 'lg' })
])
]),
m('.stat.bg-base-100.shadow.rounded-lg.p-4', [
m('.stat-title', 'Partitions'),
m('.stat-value.text-2xl', build.requested_partitions.length),
m('.stat-desc', 'requested')
]),
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-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-desc', build.started_at ? formatDateTime(build.started_at) : 'Not started')
])
])
]),
// Build Content
m('.build-content.space-y-6', [
// Partition Status Grid
m('.partition-status.card.bg-base-100.shadow-xl', [
m('.card-body', [
m('h2.card-title.text-xl.mb-4', 'Partition Status'),
build.requested_partitions.length === 0 ?
m('.text-center.py-8.text-base-content.opacity-60', 'No partitions requested') :
m('.partition-grid.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-4',
build.requested_partitions.map((partitionRef: PartitionRef) => {
const partitionStatus = this.partitionStatuses.get(partitionRef.str);
return m('.partition-card.border.border-base-300.rounded-lg.p-4', [
m('.partition-header.mb-3', [
m('a.partition-ref.font-mono.text-sm.break-all.link.link-primary', {
href: `/partitions/${encodePartitionRef(partitionRef.str)}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/partitions/${encodePartitionRef(partitionRef.str)}`);
},
title: `View details for partition: ${partitionRef.str}`
}, partitionRef.str)
]),
m('.partition-status.flex.justify-between.items-center', [
// CLEAN: Always string status, no nested object access
m(PartitionStatusBadge, {
status: partitionStatus?.status_name || 'Loading...',
size: 'sm'
}),
partitionStatus?.last_updated ?
m('.updated-time.text-xs.opacity-60',
formatTime(partitionStatus.last_updated)) :
m('.text-xs.opacity-60', '—')
])
]);
})
)
])
]),
// Build Summary
m('.build-summary.card.bg-base-100.shadow-xl', [
m('.card-body', [
m('h2.card-title.text-xl.mb-4', 'Build Summary'),
m('.grid.grid-cols-2.md:grid-cols-4.gap-4', [
m('.metric.text-center', [
m('.metric-value.text-2xl.font-bold.text-success', build.completed_jobs),
m('.metric-label.text-sm.opacity-60', 'Completed')
]),
m('.metric.text-center', [
m('.metric-value.text-2xl.font-bold.text-error', build.failed_jobs),
m('.metric-label.text-sm.opacity-60', 'Failed')
]),
m('.metric.text-center', [
m('.metric-value.text-2xl.font-bold.text-warning', build.cancelled_jobs),
m('.metric-label.text-sm.opacity-60', 'Cancelled')
]),
m('.metric.text-center', [
m('.metric-value.text-2xl.font-bold', build.total_jobs),
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))
])
]),
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.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))
])
])
])
])
]);
}
};
export const PartitionsList: TypedComponent<PartitionsListAttrs> = {
data: [] as DashboardPartition[],
loading: true,
error: null as string | null,
searchTerm: '',
totalCount: 0,
async loadPartitions() {
try {
this.loading = true;
this.error = null;
m.redraw();
// Use direct fetch since we don't have a specific service method for partition list
// TODO: Consider adding getPartitionsList() to DashboardService
const response = await fetch('/api/v1/partitions');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const apiData = (await response.json()).data;
// Transform API response to dashboard types
this.data = apiData.partitions?.map((partition: any) => ({
partition_ref: partition.partition_ref,
status_code: partition.status_code,
status_name: partition.status_name,
last_updated: partition.last_updated ?? null,
build_requests: partition.build_requests || []
})) || [];
this.totalCount = apiData.totalCount || this.data.length;
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 response = await fetch('/api/v1/builds', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
partitions: [partitionRef]
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
// Redirect to build status page
m.route.set(`/builds/${result.build_request_id}`);
} 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) return [];
if (!this.searchTerm) return this.data;
const search = this.searchTerm.toLowerCase();
return this.data.filter((partition: DashboardPartition) =>
partition.partition_ref.str.toLowerCase().includes(search)
);
},
oninit(vnode: m.Vnode<PartitionsListAttrs>) {
this.loadPartitions();
},
view(vnode: m.Vnode<PartitionsListAttrs>) {
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.totalCount} total` || "missing")
]),
m('div.form-control.mb-4', [
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: DashboardPartition) =>
m('tr.hover', [
m('td', [
m('a.link.link-primary.font-mono.text-sm.break-all', {
href: `/partitions/${encodePartitionRef(partition.partition_ref.str)}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/partitions/${encodePartitionRef(partition.partition_ref.str)}`);
},
title: partition.partition_ref.str
}, partition.partition_ref.str)
]),
m('td', [
m(PartitionStatusBadge, { status: partition.status_name })
]),
m('td.text-sm.opacity-70',
partition.last_updated ? formatTime(partition.last_updated) : '—'),
m('td', [
m('button.btn.btn-sm.btn-primary', {
onclick: () => this.buildPartition(partition.partition_ref.str)
}, 'Build'),
partition.build_requests.length > 0 ?
m('a.btn.btn-sm.btn-outline.ml-2', {
href: `/builds/${partition.build_requests[0]}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/builds/${partition.build_requests[0]}`);
},
title: 'View most recent build'
}, 'View Build') : null
])
])
)
)
])
])
])
])
])
]);
}
};
export const PartitionStatus: TypedComponent<PartitionStatusAttrs> = {
data: null as DashboardPartition | null,
events: null as any | null, // Keep as any since events structure varies
loading: true,
error: null as string | null,
partitionRef: '',
buildHistory: [] as any[], // Keep as any since this is extracted from events
async loadPartition() {
try {
this.loading = true;
this.error = null;
m.redraw();
const service = DashboardService.getInstance();
// Load partition status using our transformation layer
const partitionData = await service.getPartitionDetail(this.partitionRef);
if (!partitionData) {
throw new Error(`Partition ${this.partitionRef} not found`);
}
this.data = partitionData;
// Load partition events for build history (use direct API for now)
// TODO: Consider adding getPartitionEvents() to DashboardService
const encodedRef = btoa(this.partitionRef).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
const eventsResponse = await fetch(`/api/v1/partitions/${encodedRef}/events`);
if (eventsResponse.ok) {
this.events = await eventsResponse.json();
this.buildHistory = this.extractBuildHistory(this.events.events || []);
} else {
console.warn('Failed to load partition events:', eventsResponse.statusText);
this.events = { events: [] };
this.buildHistory = [];
}
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_name = 'Completed';
build.completedAt = event.timestamp;
} else if (event.message?.includes('failed') || event.message?.includes('error')) {
build.status_name = 'Failed';
build.completedAt = event.timestamp;
} else if (event.message?.includes('executing') || event.message?.includes('running')) {
build.status_name = 'Executing';
} else if (event.message?.includes('planning')) {
build.status_name = '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 response = await fetch('/api/v1/builds', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
partitions: [this.partitionRef]
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
// Redirect to build status page
m.route.set(`/builds/${result.build_request_id}`);
} catch (error) {
console.error('Failed to start build:', error);
alert(`Failed to start build: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
},
oninit(vnode: m.Vnode<PartitionStatusAttrs>) {
this.partitionRef = decodePartitionRef(vnode.attrs.base64_ref);
this.loadPartition();
},
view(vnode: m.Vnode<PartitionStatusAttrs>) {
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', [
// 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_name || 'Unknown', size: 'lg' }),
this.data?.last_updated ?
m('.timestamp.text-sm.opacity-70',
`Last updated: ${formatDateTime(this.data.last_updated)}`) : 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_name })
]),
m('td.text-sm.opacity-70',
formatDateTime(build.startedAt)),
m('td.text-sm.opacity-70',
build.completedAt ?
formatDateTime(build.completedAt) :
'—'),
m('td.text-sm.opacity-70', `${build.events?.length || 0} events`)
])
)
)
])
])
])
]),
// Related Build Requests
this.data?.build_requests && this.data.build_requests.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.build_requests.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, 100).map((event: any) => // Show first 100 events
m('tr.hover', [
m('td.text-xs.font-mono',
formatDateTime(event.timestamp)),
m('td', [
m(EventTypeBadge, { eventType: event.event_type, size: 'xs' })
]),
m('td',
event.build_request_id ?
m('a.link.link-primary.font-mono.text-xs', {
href: `/builds/${event.build_request_id}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/builds/${event.build_request_id}`);
}
}, event.build_request_id) : '—'),
m('td.text-xs', event.message || ''),
])
)
)
])
]),
this.events.events.length > 100 ?
m('.text-center.mt-4', [
m('.text-sm.opacity-60', `Showing first 100 of ${this.events.events.length} events`)
]) : null
])
]) : null
])
]);
}
};
export const JobsList: TypedComponent<JobsListAttrs> = {
jobs: [] as DashboardJob[],
searchTerm: '',
loading: false,
error: null as string | null,
searchTimeout: null as NodeJS.Timeout | null,
oninit(vnode: m.Vnode<JobsListAttrs>) {
JobsList.loadJobs();
},
async loadJobs() {
JobsList.loading = true;
JobsList.error = null;
try {
const service = DashboardService.getInstance();
JobsList.jobs = await service.getJobs(JobsList.searchTerm || undefined);
} catch (error) {
console.error('Failed to load jobs:', error);
JobsList.error = 'Failed to load jobs. Please try again.';
} finally {
JobsList.loading = false;
m.redraw();
}
},
filteredJobs() {
if (!JobsList.searchTerm) {
return JobsList.jobs;
}
const search = JobsList.searchTerm.toLowerCase();
return JobsList.jobs.filter((job: DashboardJob) =>
job.job_label.toLowerCase().includes(search)
);
},
view: (vnode: m.Vnode<JobsListAttrs>) => {
if (JobsList.loading) {
return m('div.container.mx-auto.p-4', [
m('div.flex.justify-center.items-center.h-64', [
m('div.loading.loading-spinner.loading-lg')
])
]);
}
if (JobsList.error) {
return m('div.container.mx-auto.p-4', [
m('div.alert.alert-error', [
m('span', JobsList.error),
m('div', [
m('button.btn.btn-sm.btn-outline', {
onclick: () => JobsList.loadJobs()
}, 'Retry')
])
])
]);
}
return m('div.container.mx-auto.p-4', [
// Jobs Header
m('.jobs-header.mb-6', [
m('h1.text-3xl.font-bold.mb-4', 'Jobs'),
m('div.flex.gap-4.items-center.mb-4', [
m('input.input.input-bordered.flex-1[placeholder="Search jobs..."]', {
value: JobsList.searchTerm,
oninput: (e: Event) => {
JobsList.searchTerm = (e.target as HTMLInputElement).value;
// Debounce search
if (JobsList.searchTimeout) clearTimeout(JobsList.searchTimeout);
JobsList.searchTimeout = setTimeout(() => JobsList.loadJobs(), 300);
}
}),
m('button.btn.btn-outline', {
onclick: () => JobsList.loadJobs()
}, 'Refresh')
])
]),
// Jobs Table
JobsList.filteredJobs().length === 0 ?
m('div.text-center.py-8.text-base-content.opacity-60', 'No jobs found') :
m('.jobs-table.card.bg-base-100.shadow-xl', [
m('.card-body.p-0', [
m('.overflow-x-auto', [
m('table.table.table-zebra', [
m('thead', [
m('tr', [
m('th', 'Job Label'),
m('th', 'Success Rate'),
m('th', 'Success/Total'),
m('th', 'Avg Partitions'),
m('th', 'Last Run'),
])
]),
m('tbody', JobsList.filteredJobs().map((job: DashboardJob) => {
// Calculate success rate
const successRate = job.total_runs > 0 ? job.successful_runs / job.total_runs : 0;
return m('tr.hover', [
m('td', [
m('a.link.link-primary.font-mono.text-sm', {
href: `/jobs/${encodeJobLabel(job.job_label)}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/jobs/${encodeJobLabel(job.job_label)}`);
}
}, job.job_label)
]),
m('td', [
m(`span.badge.${successRate >= 0.9 ? 'badge-success' : successRate >= 0.7 ? 'badge-warning' : 'badge-error'}`,
`${Math.round(successRate * 100)}%`)
]),
m('td', `${job.successful_runs}/${job.total_runs}`),
m('td', job.average_partitions_per_run?.toFixed(1) || '—'),
m('td.text-sm.opacity-70',
job.last_run_timestamp ? formatTime(job.last_run_timestamp) : '—'),
]);
}))
])
])
])
])
]);
}
};
export const JobMetrics: TypedComponent<JobMetricsAttrs> = {
jobLabel: '',
metrics: null as DashboardJob | null,
loading: false,
error: null as string | null,
oninit(vnode: m.Vnode<JobMetricsAttrs>) {
JobMetrics.jobLabel = decodeJobLabel(vnode.attrs.label);
JobMetrics.loadJobMetrics();
},
async loadJobMetrics() {
JobMetrics.loading = true;
JobMetrics.error = null;
try {
const service = DashboardService.getInstance();
JobMetrics.metrics = await service.getJobMetrics(JobMetrics.jobLabel);
if (!JobMetrics.metrics) {
JobMetrics.error = 'Job not found or no metrics available';
}
} catch (error) {
console.error('Failed to load job metrics:', error);
JobMetrics.error = 'Failed to load job metrics. Please try again.';
} finally {
JobMetrics.loading = false;
m.redraw();
}
},
view: (vnode: m.Vnode<JobMetricsAttrs>) => {
if (JobMetrics.loading) {
return m('div.container.mx-auto.p-4', [
m('div.flex.justify-center.items-center.h-64', [
m('div.loading.loading-spinner.loading-lg')
])
]);
}
if (JobMetrics.error) {
return m('div.container.mx-auto.p-4', [
m('div.alert.alert-error', [
m('span', JobMetrics.error),
m('div', [
m('button.btn.btn-sm.btn-outline', {
onclick: () => JobMetrics.loadJobMetrics()
}, 'Retry')
])
])
]);
}
if (!JobMetrics.metrics) {
return m('div.container.mx-auto.p-4', [
m('div.text-center.py-8.text-base-content.opacity-60', 'No metrics available')
]);
}
const successRate = JobMetrics.metrics.total_runs > 0 ?
JobMetrics.metrics.successful_runs / JobMetrics.metrics.total_runs : 0;
return m('div.container.mx-auto.p-4', [
// Job Header
m('.job-header.mb-6', [
m('h1.text-3xl.font-bold.mb-4', [
'Job Metrics: ',
m('span.font-mono.text-2xl', JobMetrics.jobLabel)
]),
m('.job-stats.grid.grid-cols-1.md:grid-cols-4.gap-4.mb-6', [
m('.stat.bg-base-100.shadow-xl.rounded-lg.p-4', [
m('.stat-title', 'Success Rate'),
m('.stat-value.text-3xl', [
m(`span.${successRate >= 0.9 ? 'text-success' : successRate >= 0.7 ? 'text-warning' : 'text-error'}`,
`${Math.round(successRate * 100)}%`)
]),
m('.stat-desc', `${JobMetrics.metrics.successful_runs}/${JobMetrics.metrics.total_runs}`)
]),
m('.stat.bg-base-100.shadow-xl.rounded-lg.p-4', [
m('.stat-title', 'Total Runs'),
m('.stat-value.text-3xl', JobMetrics.metrics.total_runs),
m('.stat-desc', `${JobMetrics.metrics.failed_runs} failed, ${JobMetrics.metrics.cancelled_runs} cancelled`)
]),
m('.stat.bg-base-100.shadow-xl.rounded-lg.p-4', [
m('.stat-title', 'Last Run'),
m('.stat-value.text-2xl', [
m(`span.badge.${JobMetrics.metrics.last_run_status === 'COMPLETED' ? 'badge-success' :
JobMetrics.metrics.last_run_status === 'FAILED' ? 'badge-error' : 'badge-warning'}`,
JobMetrics.metrics.last_run_status)
]),
m('.stat-desc', JobMetrics.metrics.last_run_timestamp ? formatTime(JobMetrics.metrics.last_run_timestamp) : '—')
]),
m('.stat.bg-base-100.shadow-xl.rounded-lg.p-4', [
m('.stat-title', 'Avg Partitions'),
m('.stat-value.text-3xl', JobMetrics.metrics.average_partitions_per_run?.toFixed(1) || '—'),
m('.stat-desc', 'per run')
]),
])
]),
// Main Content
m('.job-content.space-y-6', [
// Recent Builds Summary
JobMetrics.metrics.recent_builds?.length > 0 && m('.recent-builds-summary.card.bg-base-100.shadow-xl', [
m('.card-body', [
m('h2.card-title.text-xl.mb-4', `Recent Builds (${JobMetrics.metrics.recent_builds.length})`),
m('.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-3',
JobMetrics.metrics.recent_builds.slice(0, 9).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)
])
)
),
JobMetrics.metrics.recent_builds.length > 9 &&
m('.text-center.mt-4.text-sm.opacity-60',
`Showing 9 of ${JobMetrics.metrics.recent_builds.length} recent builds`)
])
]),
// Job Summary Stats
m('.job-summary.card.bg-base-100.shadow-xl', [
m('.card-body', [
m('h2.card-title.text-xl.mb-4', 'Job Summary'),
m('.grid.grid-cols-2.md:grid-cols-4.gap-4', [
m('.metric.text-center', [
m('.metric-value.text-2xl.font-bold.text-success', JobMetrics.metrics.successful_runs),
m('.metric-label.text-sm.opacity-60', 'Successful')
]),
m('.metric.text-center', [
m('.metric-value.text-2xl.font-bold.text-error', JobMetrics.metrics.failed_runs),
m('.metric-label.text-sm.opacity-60', 'Failed')
]),
m('.metric.text-center', [
m('.metric-value.text-2xl.font-bold.text-warning', JobMetrics.metrics.cancelled_runs),
m('.metric-label.text-sm.opacity-60', 'Cancelled')
]),
m('.metric.text-center', [
m('.metric-value.text-2xl.font-bold', JobMetrics.metrics.average_partitions_per_run?.toFixed(1) || '0'),
m('.metric-label.text-sm.opacity-60', 'Avg Partitions')
])
])
])
])
])
]);
}
};
export const GraphAnalysis: TypedComponent<GraphAnalysisAttrs> = {
view: (vnode: m.Vnode<GraphAnalysisAttrs>) => m('div.container.mx-auto.p-4', [
m('h1.text-3xl.font-bold.mb-4', 'Graph Analysis'),
m('div.card.bg-base-100.shadow-xl', [
m('div.card-body', [
m('h2.card-title', 'Interactive Build Graph'),
m('p', 'Analyze partition dependencies and execution plans.'),
m('div.form-control.mb-4', [
m('label.label', [
m('span.label-text', 'Partition References'),
]),
m('textarea.textarea.textarea-bordered[placeholder="Enter partition references to analyze..."]'),
]),
m('div.card-actions.justify-end', [
m('button.btn.btn-primary', 'Analyze Graph'),
]),
]),
]),
])
};