1346 lines
No EOL
50 KiB
TypeScript
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'),
|
|
]),
|
|
]),
|
|
]),
|
|
])
|
|
}; |