Partitions page

This commit is contained in:
soaxelbrooke 2025-07-12 13:48:41 -07:00
parent 0f51edb4b8
commit a167339dac
4 changed files with 514 additions and 35 deletions

View file

@ -1,6 +1,6 @@
import m from 'mithril';
import { DashboardService, pollingManager, formatTime, formatDateTime, RecentActivitySummary } from './services';
import { encodePartitionRef, BuildStatusBadge, PartitionStatusBadge, EventTypeBadge } from './utils';
import { encodePartitionRef, decodePartitionRef, BuildStatusBadge, PartitionStatusBadge, EventTypeBadge } from './utils';
// Page scaffold components
export const RecentActivity = {
@ -500,41 +500,476 @@ export const BuildStatus = {
};
export const PartitionsList = {
view: () => m('div.container.mx-auto.p-4', [
m('h1.text-3xl.font-bold.mb-4', 'Partitions'),
m('div.card.bg-base-100.shadow-xl', [
m('div.card-body', [
m('h2.card-title', 'Partition Listing'),
m('p', 'Searchable list of recently built partitions with build triggers.'),
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.partitionRef.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[placeholder="Search partitions..."]'),
]),
m('div.alert.alert-info', [
m('span', 'Partition list will be populated from the service API.'),
]),
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.partitionRef)}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/partitions/${encodePartitionRef(partition.partitionRef)}`);
},
title: partition.partitionRef
}, partition.partitionRef)
]),
m('td', [
m(PartitionStatusBadge, { status: partition.status })
]),
m('td.text-sm.opacity-70', formatTime(new Date(partition.updatedAt / 1000000).toISOString())),
m('td', [
m('button.btn.btn-sm.btn-primary', {
onclick: () => this.buildPartition(partition.partitionRef)
}, 'Build'),
partition.buildRequestId ?
m('a.btn.btn-sm.btn-outline.ml-2', {
href: `/builds/${partition.buildRequestId}`,
onclick: (e: Event) => {
e.preventDefault();
m.route.set(`/builds/${partition.buildRequestId}`);
}
}, 'View Build') : null
])
])
)
)
])
])
])
])
])
]);
}
};
export const PartitionStatus = {
view: (vnode: any) => {
const encodedRef = vnode.attrs.base64_ref;
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', [
m('h1.text-3xl.font-bold.mb-4', 'Partition Status'),
m('div.card.bg-base-100.shadow-xl', [
m('div.card-body', [
m('h2.card-title', 'Partition Details'),
m('p', 'Partition lifecycle, build history, and related information.'),
m('div.alert.alert-info', [
m('span', `Encoded reference: ${encodedRef}`),
]),
m('div.card-actions.justify-end', [
m('button.btn.btn-primary', 'Build Now'),
m('button.btn.btn-secondary', 'Force Rebuild'),
// 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 / 1000000).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 / 1000000).toISOString())),
m('td.text-sm.opacity-70',
build.completedAt ?
formatDateTime(new Date(build.completedAt / 1000000).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 / 1000000).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
])
]);
}
};

View file

@ -290,15 +290,56 @@ impl BuildEventLog for SqliteBuildEventLog {
async fn get_partition_events(
&self,
_partition_ref: &str,
_since: Option<i64>
partition_ref: &str,
since: Option<i64>
) -> Result<Vec<BuildEvent>> {
// This method is not implemented because it would require complex joins
// to reconstruct complete event data. Use get_build_request_events instead
// which properly reconstructs all event types for a build request.
Err(BuildEventLogError::QueryError(
"get_partition_events is not implemented - use get_build_request_events to get complete event data".to_string()
))
// First get the build request IDs (release the connection lock quickly)
let build_ids: Vec<String> = {
let conn = self.connection.lock().unwrap();
// Get all events for builds that included this partition
// First find all build request IDs that have events for this partition
let build_ids_query = if since.is_some() {
"SELECT DISTINCT be.build_request_id
FROM build_events be
JOIN partition_events pe ON be.event_id = pe.event_id
WHERE pe.partition_ref = ? AND be.timestamp > ?"
} else {
"SELECT DISTINCT be.build_request_id
FROM build_events be
JOIN partition_events pe ON be.event_id = pe.event_id
WHERE pe.partition_ref = ?"
};
let mut stmt = conn.prepare(build_ids_query)
.map_err(|e| BuildEventLogError::QueryError(e.to_string()))?;
let row_mapper = |row: &Row| -> rusqlite::Result<String> {
Ok(row.get::<_, String>(0)?)
};
let build_ids_result: Vec<String> = if let Some(since_timestamp) = since {
stmt.query_map(params![partition_ref, since_timestamp], row_mapper)
} else {
stmt.query_map(params![partition_ref], row_mapper)
}.map_err(|e| BuildEventLogError::QueryError(e.to_string()))?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| BuildEventLogError::QueryError(e.to_string()))?;
build_ids_result
}; // Connection lock is released here
// Now get all events for those build requests (this gives us complete event reconstruction)
let mut all_events = Vec::new();
for build_id in build_ids {
let events = self.get_build_request_events(&build_id, since).await?;
all_events.extend(events);
}
// Sort events by timestamp
all_events.sort_by_key(|e| e.timestamp);
Ok(all_events)
}
async fn get_job_run_events(

View file

@ -185,6 +185,7 @@ pub async fn get_build_status(
timestamp: e.timestamp,
event_type: event_type_to_string(&e.event_type),
message: event_to_message(&e.event_type),
build_request_id: e.build_request_id,
job_label,
partition_ref,
delegated_build_id,
@ -324,6 +325,7 @@ pub async fn get_partition_events(
timestamp: e.timestamp,
event_type: event_type_to_string(&e.event_type),
message: event_to_message(&e.event_type),
build_request_id: e.build_request_id,
job_label,
partition_ref,
delegated_build_id,

View file

@ -63,6 +63,7 @@ pub struct BuildEventSummary {
pub timestamp: i64,
pub event_type: String,
pub message: String,
pub build_request_id: String, // Build request ID for navigation
// Navigation-relevant fields (populated based on event type)
pub job_label: Option<String>, // For job events
pub partition_ref: Option<String>, // For partition events