databuild/databuild/dashboard/pages.ts
soaxelbrooke b70a6ce9a7
Some checks are pending
/ setup (push) Waiting to run
timestamp stuff
2025-07-16 19:40:38 -07:00

1288 lines
No EOL
46 KiB
TypeScript

import m from 'mithril';
import { DashboardService, pollingManager, formatTime, formatDateTime, formatDuration, formatDate, RecentActivitySummary } from './services';
import { encodePartitionRef, decodePartitionRef, encodeJobLabel, decodeJobLabel, BuildStatusBadge, PartitionStatusBadge, EventTypeBadge } from './utils';
// Page scaffold components
export const RecentActivity = {
data: null as RecentActivitySummary | 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() {
// 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() {
// Clean up polling when component is removed
pollingManager.stopPolling('recent-activity');
},
view: function() {
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.graphName)
]),
// Statistics
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.activeBuilds),
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.recentBuilds.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.totalPartitions),
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.recentBuilds.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.recentBuilds.map(build =>
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', formatTime(build.createdAt)),
])
)
)
])
])
])
]),
// 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.recentPartitions.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.recentPartitions.map(partition =>
m('tr.hover', [
m('td', [
m('a.link.link-primary.font-mono.text-sm.break-all', {
href: `/partitions/${encodePartitionRef(partition.ref)}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/partitions/${encodePartitionRef(partition.ref)}`);
},
title: partition.ref
}, partition.ref)
]),
m('td', [
m(PartitionStatusBadge, { status: partition.status })
]),
m('td.text-sm.opacity-70', formatTime(partition.updatedAt)),
])
)
)
])
])
])
])
])
]);
}
};
export const BuildStatus = {
data: null as any | null,
loading: true,
error: null as string | null,
partitionStatuses: new Map<string, any>(),
buildId: '',
oninit(vnode: any) {
this.buildId = vnode.attrs.id;
this.loadBuild();
this.startPolling();
},
onremove() {
pollingManager.stopPolling(`build-status-${this.buildId}`);
},
async loadBuild() {
try {
this.loading = true;
this.error = null;
m.redraw();
// Import types dynamically to avoid circular dependencies
const { DefaultApi, Configuration } = await import('../client/typescript_generated/src/index');
const apiClient = new DefaultApi(new Configuration({ basePath: '' }));
// Get build status
const buildResponse = await apiClient.apiV1BuildsBuildRequestIdGet({ buildRequestId: this.buildId });
this.data = buildResponse;
// Load partition statuses for all requested partitions
if (buildResponse.requestedPartitions) {
for (const partitionRef of buildResponse.requestedPartitions) {
try {
const partitionStatus = await apiClient.apiV1PartitionsRefStatusGet({
ref: partitionRef
});
this.partitionStatuses.set(partitionRef, partitionStatus);
} catch (e) {
console.warn(`Failed to load status for partition ${partitionRef}:`, 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 === 'BuildRequestExecuting' ||
this.data?.status === 'BuildRequestPlanning';
const interval = isActive ? 2000 : 10000; // 2s for active, 10s for completed
pollingManager.startPolling(`build-status-${this.buildId}`, () => {
this.loadBuild();
}, interval);
},
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;
}
},
view() {
// Loading/error states similar to RecentActivity component
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');
return m('div.container.mx-auto.p-4', [
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(BuildStatusBadge, { status: this.data.status, size: 'lg' }),
m('.timestamp.text-sm.opacity-70', formatDateTime(new Date(this.data.createdAt).toISOString())),
m('.partitions.text-sm.opacity-70', `${this.data.requestedPartitions?.length || 0} partitions`),
])
]),
m('.build-content.space-y-6', [
m('.partition-status.card.bg-base-100.shadow-xl', [
m('.card-body', [
m('h2.card-title.text-xl.mb-4', 'Partition Status'),
m('.partition-grid.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-3',
this.data.requestedPartitions?.map((partitionRef: string) => {
const status = this.partitionStatuses.get(partitionRef);
return m('.partition-card.border.border-base-300.rounded.p-3', [
m('a.partition-ref.font-mono.text-sm.break-all.mb-2.link.link-primary', {
href: `/partitions/${encodePartitionRef(partitionRef)}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/partitions/${encodePartitionRef(partitionRef)}`);
},
title: `View details for partition: ${partitionRef}`
}, partitionRef),
m('.flex.justify-between.items-center', [
m(PartitionStatusBadge, { status: status?.status || 'Unknown' }),
status?.lastUpdated ?
m('.updated-time.text-xs.opacity-60',
formatDateTime(new Date(status.lastUpdated).toISOString())) : null
])
]);
}) || [m('.text-center.py-8.text-base-content.opacity-60', 'No partitions')]
)
])
]),
m('.execution-events.card.bg-base-100.shadow-xl', [
m('.card-body', [
m('h2.card-title.text-xl.mb-4', 'Build Events'),
this.data.events?.length > 0 ?
m('.overflow-x-auto', [
m('table.table.table-sm', [
m('thead', [
m('tr', [
m('th', 'Timestamp'),
m('th', 'Event Type'),
m('th', 'Message'),
m('th', 'Link')
])
]),
m('tbody',
this.data.events.map((event: any) =>
m('tr.hover', [
m('td.text-xs.font-mono',
formatDateTime(new Date(event.timestamp).toISOString())),
m('td', [
m(EventTypeBadge, { eventType: event.eventType })
]),
m('td.text-sm', event.message || ''),
m('td', [
(() => {
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', '—');
})()
])
])
)
)
])
]) :
m('.text-center.py-8.text-base-content.opacity-60', 'No events')
])
])
])
]);
}
};
export const PartitionsList = {
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.partition_ref.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.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.partition_ref)}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/partitions/${encodePartitionRef(partition.partition_ref)}`);
},
title: partition.partition_ref
}, partition.partition_ref)
]),
m('td', [
m(PartitionStatusBadge, { status: partition.status })
]),
m('td.text-sm.opacity-70', formatTime(new Date(partition.updated_at).toISOString())),
m('td', [
m('button.btn.btn-sm.btn-primary', {
onclick: () => this.buildPartition(partition.partition_ref)
}, 'Build'),
partition.build_request_id ?
m('a.btn.btn-sm.btn-outline.ml-2', {
href: `/builds/${partition.build_request_id}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/builds/${partition.build_request_id}`);
}
}, 'View Build') : null
])
])
)
)
])
])
])
])
])
]);
}
};
export const PartitionStatus = {
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', [
// 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).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).toISOString())),
m('td.text-sm.opacity-70',
build.completedAt ?
formatDateTime(new Date(build.completedAt).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).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
])
]);
}
};
export const JobsList = {
jobs: [] as any[],
searchTerm: '',
loading: false,
error: null as string | null,
searchTimeout: null as NodeJS.Timeout | null,
oninit(vnode: any) {
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: any) =>
job.job_label.toLowerCase().includes(search)
);
},
view: () => {
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', 'Avg Duration'),
m('th', 'Recent Runs'),
m('th', 'Last Run'),
])
]),
m('tbody', JobsList.filteredJobs().map((job: any) =>
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.${job.success_rate >= 0.9 ? 'badge-success' : job.success_rate >= 0.7 ? 'badge-warning' : 'badge-error'}`,
`${Math.round(job.success_rate * 100)}%`)
]),
m('td', formatDuration(job.avg_duration_ms)),
m('td', (job.recent_runs || 0).toString()),
m('td.text-sm.opacity-70',
job.last_run ? formatTime(new Date(job.last_run).toISOString()) : '—'),
])
))
])
])
])
])
]);
}
};
export const JobMetrics = {
jobLabel: '',
metrics: null as any,
loading: false,
error: null as string | null,
oninit(vnode: any) {
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: () => {
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')
]);
}
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-3.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.${JobMetrics.metrics.success_rate >= 0.9 ? 'text-success' : JobMetrics.metrics.success_rate >= 0.7 ? 'text-warning' : 'text-error'}`,
`${Math.round(JobMetrics.metrics.success_rate * 100)}%`)
]),
]),
m('.stat.bg-base-100.shadow-xl.rounded-lg.p-4', [
m('.stat-title', 'Avg Duration'),
m('.stat-value.text-3xl', formatDuration(JobMetrics.metrics.avg_duration_ms)),
]),
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),
]),
])
]),
// Main Content
m('.job-content.space-y-6', [
// Performance Trends
JobMetrics.metrics.daily_stats?.length > 0 && m('.performance-trends.card.bg-base-100.shadow-xl', [
m('.card-body', [
m('h2.card-title.text-xl.mb-4', 'Performance Trends (Last 30 Days)'),
m('.overflow-x-auto', [
m('table.table.table-sm', [
m('thead', [
m('tr', [
m('th', 'Date'),
m('th', 'Success Rate'),
m('th', 'Avg Duration'),
m('th', 'Total Runs'),
])
]),
m('tbody', JobMetrics.metrics.daily_stats.map((stat: any) =>
m('tr.hover', [
m('td', formatDate(stat.date)),
m('td', [
m(`span.badge.${stat.success_rate >= 0.9 ? 'badge-success' : stat.success_rate >= 0.7 ? 'badge-warning' : 'badge-error'}`,
`${Math.round(stat.success_rate * 100)}%`)
]),
m('td', formatDuration(stat.avg_duration_ms)),
m('td', stat.total_runs),
])
))
])
])
])
]),
// Recent Runs
m('.recent-runs.card.bg-base-100.shadow-xl', [
m('.card-body', [
m('h2.card-title.text-xl.mb-4', `Recent Runs (${JobMetrics.metrics.recent_runs?.length || 0})`),
!JobMetrics.metrics.recent_runs || JobMetrics.metrics.recent_runs.length === 0 ?
m('.text-center.py-8.text-base-content.opacity-60', 'No recent runs available') :
m('.overflow-x-auto', [
m('table.table.table-sm', [
m('thead', [
m('tr', [
m('th', 'Build Request'),
m('th', 'Partitions'),
m('th', 'Status'),
m('th', 'Duration'),
m('th', 'Started'),
])
]),
m('tbody', JobMetrics.metrics.recent_runs.map((run: any) =>
m('tr.hover', [
m('td', [
m('a.link.link-primary.font-mono.text-sm', {
href: `/builds/${run.build_request_id}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/builds/${run.build_request_id}`);
}
}, run.build_request_id)
]),
m('td.text-sm', [
m('span.font-mono', run.partitions.slice(0, 3).join(', ')),
run.partitions.length > 3 && m('span.opacity-60', ` +${run.partitions.length - 3} more`)
]),
m('td', [
m(`span.badge.${run.status === 'completed' ? 'badge-success' :
run.status === 'failed' ? 'badge-error' :
run.status === 'running' ? 'badge-warning' : 'badge-info'}`,
run.status)
]),
m('td', formatDuration(run.duration_ms)),
m('td.text-sm.opacity-70',
formatTime(new Date(run.started_at).toISOString())),
])
))
])
])
])
])
])
]);
}
};
export const GraphAnalysis = {
view: () => 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'),
]),
]),
]),
])
};