This commit is contained in:
parent
97ad905f6b
commit
24482e2cc4
18 changed files with 1230 additions and 445 deletions
|
|
@ -436,7 +436,7 @@ async fn handle_partitions_command(matches: &ArgMatches, event_log_uri: &str) ->
|
|||
let last_updated = format_timestamp(partition.last_updated);
|
||||
|
||||
println!("{:<30} {:<15} {:<12} {:<12} {:<20}",
|
||||
partition.partition_ref,
|
||||
partition.partition_ref.map(|p| p.str).unwrap_or("".to_string()),
|
||||
partition.status_name, // Use human-readable status name
|
||||
partition.builds_count,
|
||||
partition.invalidation_count,
|
||||
|
|
@ -465,7 +465,7 @@ async fn handle_partitions_command(matches: &ArgMatches, event_log_uri: &str) ->
|
|||
println!("{}", json);
|
||||
}
|
||||
_ => {
|
||||
println!("Partition: {}", detail.partition_ref);
|
||||
println!("Partition: {}", detail.partition_ref.map(|p| p.str).unwrap_or("".to_string()));
|
||||
println!("Status: {} ({})", detail.status_name, detail.status_code);
|
||||
println!("Builds involved: {}", detail.builds_count);
|
||||
println!("Invalidation count: {}", detail.invalidation_count);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"moduleResolution": "node",
|
||||
"allowJs": true,
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
|
@ -14,5 +14,8 @@
|
|||
"noEmit": false
|
||||
},
|
||||
"include": ["**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -49,12 +49,6 @@ ts_config(
|
|||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
ts_config(
|
||||
name = "ts_config_test",
|
||||
src = ":tsconfig_test.json",
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
# Making modules of ts projects seems to be a rats nest.
|
||||
# Hopefully we can figure this out in the future.
|
||||
ts_project(
|
||||
|
|
@ -66,6 +60,10 @@ ts_project(
|
|||
"services.ts",
|
||||
"types.ts",
|
||||
"utils.ts",
|
||||
# Test files
|
||||
"index.test.ts",
|
||||
"utils.test.ts",
|
||||
"transformation-tests.ts",
|
||||
],
|
||||
allow_js = True,
|
||||
resolve_json_module = True,
|
||||
|
|
@ -74,7 +72,9 @@ ts_project(
|
|||
deps = [
|
||||
":node_modules/@types/mithril",
|
||||
":node_modules/@types/node",
|
||||
":node_modules/@types/ospec",
|
||||
":node_modules/mithril",
|
||||
":node_modules/ospec",
|
||||
":node_modules/whatwg-fetch",
|
||||
"//databuild/client:typescript_lib",
|
||||
],
|
||||
|
|
@ -91,30 +91,21 @@ esbuild(
|
|||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
ts_project(
|
||||
name = "test_app",
|
||||
testonly = True,
|
||||
srcs = [
|
||||
"index.test.ts",
|
||||
"utils.test.ts",
|
||||
],
|
||||
allow_js = True,
|
||||
resolve_json_module = True,
|
||||
transpiler = "tsc",
|
||||
tsconfig = ":ts_config_test",
|
||||
deps = [
|
||||
":app",
|
||||
":node_modules/@types/mithril",
|
||||
":node_modules/@types/node",
|
||||
":node_modules/@types/ospec",
|
||||
":node_modules/mithril",
|
||||
":node_modules/ospec",
|
||||
],
|
||||
)
|
||||
|
||||
js_test(
|
||||
name = "app_test",
|
||||
chdir = package_name(),
|
||||
data = [":test_app"],
|
||||
data = [":app"],
|
||||
entry_point = "index.test.js",
|
||||
)
|
||||
|
||||
# Test to verify strict TypeScript configuration catches expected failures
|
||||
sh_test(
|
||||
name = "strict_config_test",
|
||||
srcs = ["test-strict-config.sh"],
|
||||
data = [
|
||||
"test-data/strict-config-failures.ts",
|
||||
"tsconfig_app.json",
|
||||
":node_modules/@types/node",
|
||||
":node_modules/typescript",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
const { appName } = require('./index');
|
||||
const o = require('ospec');
|
||||
|
||||
// Import transformation tests
|
||||
require('./transformation-tests');
|
||||
|
||||
o.spec("appName", () => {
|
||||
o("should be databuild", () => {
|
||||
o(appName).equals("databuild") `Should be databuild`;
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
// TODO - I think we can create an ospec target that invokes these with the ospec CLI?
|
||||
// https://github.com/MithrilJS/ospec?tab=readme-ov-file#command-line-interface
|
||||
|
|
|
|||
|
|
@ -26,15 +26,31 @@ export const appName = "databuild";
|
|||
|
||||
// Wrapper components that include layout - now with type safety
|
||||
function createLayoutWrapper<TAttrs>(component: TypedComponent<TAttrs>): m.Component<TAttrs> {
|
||||
return {
|
||||
oninit: component.oninit ? (vnode: m.Vnode<TAttrs>) => component.oninit!.call(component, vnode) : undefined,
|
||||
oncreate: component.oncreate ? (vnode: m.VnodeDOM<TAttrs>) => component.oncreate!.call(component, vnode) : undefined,
|
||||
onupdate: component.onupdate ? (vnode: m.VnodeDOM<TAttrs>) => component.onupdate!.call(component, vnode) : undefined,
|
||||
onbeforeremove: component.onbeforeremove ? (vnode: m.VnodeDOM<TAttrs>) => component.onbeforeremove!.call(component, vnode) : undefined,
|
||||
onremove: component.onremove ? (vnode: m.VnodeDOM<TAttrs>) => component.onremove!.call(component, vnode) : undefined,
|
||||
onbeforeupdate: component.onbeforeupdate ? (vnode: m.Vnode<TAttrs>, old: m.VnodeDOM<TAttrs>) => component.onbeforeupdate!.call(component, vnode, old) : undefined,
|
||||
const wrapper: any = {
|
||||
view: (vnode: m.Vnode<TAttrs>) => m(Layout, [component.view.call(component, vnode)])
|
||||
};
|
||||
|
||||
// Only add lifecycle methods if they exist to avoid exactOptionalPropertyTypes issues
|
||||
if (component.oninit) {
|
||||
wrapper.oninit = (vnode: m.Vnode<TAttrs>) => component.oninit!.call(component, vnode);
|
||||
}
|
||||
if (component.oncreate) {
|
||||
wrapper.oncreate = (vnode: m.VnodeDOM<TAttrs>) => component.oncreate!.call(component, vnode);
|
||||
}
|
||||
if (component.onupdate) {
|
||||
wrapper.onupdate = (vnode: m.VnodeDOM<TAttrs>) => component.onupdate!.call(component, vnode);
|
||||
}
|
||||
if (component.onbeforeremove) {
|
||||
wrapper.onbeforeremove = (vnode: m.VnodeDOM<TAttrs>) => component.onbeforeremove!.call(component, vnode);
|
||||
}
|
||||
if (component.onremove) {
|
||||
wrapper.onremove = (vnode: m.VnodeDOM<TAttrs>) => component.onremove!.call(component, vnode);
|
||||
}
|
||||
if (component.onbeforeupdate) {
|
||||
wrapper.onbeforeupdate = (vnode: m.Vnode<TAttrs>, old: m.VnodeDOM<TAttrs>) => component.onbeforeupdate!.call(component, vnode, old);
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// Route definitions with type safety
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import m from 'mithril';
|
||||
import { DashboardService, pollingManager, formatTime, formatDateTime, formatDuration, formatDate, RecentActivitySummary } from './services';
|
||||
import { DashboardService, pollingManager, formatTime, formatDateTime, formatDuration, formatDate } from './services';
|
||||
import { encodePartitionRef, decodePartitionRef, encodeJobLabel, decodeJobLabel, BuildStatusBadge, PartitionStatusBadge, EventTypeBadge } from './utils';
|
||||
import {
|
||||
TypedComponent,
|
||||
|
|
@ -10,12 +10,19 @@ import {
|
|||
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 RecentActivitySummary | null,
|
||||
data: null as DashboardActivity | null,
|
||||
loading: true,
|
||||
error: null as string | null,
|
||||
pollInterval: null as NodeJS.Timeout | null,
|
||||
|
|
@ -104,10 +111,10 @@ export const RecentActivity: TypedComponent<RecentActivityAttrs> = {
|
|||
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)
|
||||
m('div.badge.badge-primary.badge-lg', data.graph_name)
|
||||
]),
|
||||
|
||||
// Statistics
|
||||
// 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', [
|
||||
|
|
@ -125,7 +132,7 @@ export const RecentActivity: TypedComponent<RecentActivityAttrs> = {
|
|||
])
|
||||
]),
|
||||
m('div.stat-title', 'Active Builds'),
|
||||
m('div.stat-value.text-primary', data.activeBuilds),
|
||||
m('div.stat-value.text-primary', data.active_builds_count),
|
||||
m('div.stat-desc', 'Currently running')
|
||||
]),
|
||||
m('div.stat', [
|
||||
|
|
@ -144,7 +151,7 @@ export const RecentActivity: TypedComponent<RecentActivityAttrs> = {
|
|||
])
|
||||
]),
|
||||
m('div.stat-title', 'Recent Builds'),
|
||||
m('div.stat-value.text-secondary', data.recentBuilds.length),
|
||||
m('div.stat-value.text-secondary', data.recent_builds.length),
|
||||
m('div.stat-desc', 'In the last hour')
|
||||
]),
|
||||
m('div.stat', [
|
||||
|
|
@ -163,7 +170,7 @@ export const RecentActivity: TypedComponent<RecentActivityAttrs> = {
|
|||
])
|
||||
]),
|
||||
m('div.stat-title', 'Total Partitions'),
|
||||
m('div.stat-value.text-accent', data.totalPartitions),
|
||||
m('div.stat-value.text-accent', data.total_partitions_count),
|
||||
m('div.stat-desc', 'Managed partitions')
|
||||
])
|
||||
])
|
||||
|
|
@ -189,7 +196,7 @@ export const RecentActivity: TypedComponent<RecentActivityAttrs> = {
|
|||
]),
|
||||
'Recent Build Requests'
|
||||
]),
|
||||
data.recentBuilds.length === 0
|
||||
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', [
|
||||
|
|
@ -201,21 +208,22 @@ export const RecentActivity: TypedComponent<RecentActivityAttrs> = {
|
|||
])
|
||||
]),
|
||||
m('tbody',
|
||||
data.recentBuilds.map((build: any) =>
|
||||
data.recent_builds.map((build: DashboardBuild) =>
|
||||
m('tr.hover', [
|
||||
m('td', [
|
||||
m('a.link.link-primary.font-mono.text-sm', {
|
||||
href: `/builds/${build.buildRequestId}`,
|
||||
href: `/builds/${build.build_request_id}`,
|
||||
onclick: (e: Event) => {
|
||||
e.preventDefault();
|
||||
m.route.set(`/builds/${build.buildRequestId}`);
|
||||
m.route.set(`/builds/${build.build_request_id}`);
|
||||
}
|
||||
}, build.buildRequestId)
|
||||
}, build.build_request_id)
|
||||
]),
|
||||
m('td', [
|
||||
m(BuildStatusBadge, { status: build.status })
|
||||
// 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.createdAt)),
|
||||
m('td.text-sm.opacity-70', formatTime(build.requested_at)),
|
||||
])
|
||||
)
|
||||
)
|
||||
|
|
@ -242,7 +250,7 @@ export const RecentActivity: TypedComponent<RecentActivityAttrs> = {
|
|||
]),
|
||||
'Recent Partition Builds'
|
||||
]),
|
||||
data.recentPartitions.length === 0
|
||||
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', [
|
||||
|
|
@ -254,22 +262,26 @@ export const RecentActivity: TypedComponent<RecentActivityAttrs> = {
|
|||
])
|
||||
]),
|
||||
m('tbody',
|
||||
data.recentPartitions.map((partition: any) =>
|
||||
data.recent_partitions.map((partition: DashboardPartition) =>
|
||||
m('tr.hover', [
|
||||
m('td', [
|
||||
m('a.link.link-primary.font-mono.text-sm.break-all', {
|
||||
href: `/partitions/${encodePartitionRef(partition.ref)}`,
|
||||
// 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.ref)}`);
|
||||
m.route.set(`/partitions/${encodePartitionRef(partition.partition_ref.str)}`);
|
||||
},
|
||||
title: partition.ref
|
||||
}, partition.ref)
|
||||
title: partition.partition_ref.str
|
||||
}, partition.partition_ref.str)
|
||||
]),
|
||||
m('td', [
|
||||
m(PartitionStatusBadge, { status: partition.status })
|
||||
// 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', formatTime(partition.updatedAt)),
|
||||
m('td.text-sm.opacity-70',
|
||||
// KEY FIX: Proper null handling for last_updated
|
||||
partition.last_updated ? formatTime(partition.last_updated) : '—'),
|
||||
])
|
||||
)
|
||||
)
|
||||
|
|
@ -282,11 +294,22 @@ export const RecentActivity: TypedComponent<RecentActivityAttrs> = {
|
|||
}
|
||||
};
|
||||
|
||||
/*
|
||||
// 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 any | null,
|
||||
data: null as DashboardBuild | null,
|
||||
loading: true,
|
||||
error: null as string | null,
|
||||
partitionStatuses: new Map<string, any>(),
|
||||
partitionStatuses: new Map<string, DashboardPartition>(),
|
||||
buildId: '',
|
||||
|
||||
oninit(vnode: m.Vnode<BuildStatusAttrs>) {
|
||||
|
|
@ -305,26 +328,26 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
|
|||
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: '' }));
|
||||
const service = DashboardService.getInstance();
|
||||
|
||||
// Get build status
|
||||
const buildResponse = await apiClient.apiV1BuildsBuildRequestIdGet({ build_request_id: this.buildId });
|
||||
this.data = buildResponse;
|
||||
// Get build details using our transformation layer
|
||||
const buildData = await service.getBuildDetail(this.buildId);
|
||||
if (!buildData) {
|
||||
throw new Error(`Build ${this.buildId} not found`);
|
||||
}
|
||||
|
||||
// Load partition statuses for all requested partitions
|
||||
if (buildResponse.requested_partitions) {
|
||||
for (const partition_ref of buildResponse.requested_partitions) {
|
||||
try {
|
||||
const partition_status = await apiClient.apiV1PartitionsPartitionRefStatusGet({
|
||||
partition_ref: partition_ref.str
|
||||
});
|
||||
console.log(`Loaded status for partition ${partition_ref.str}:`, partition_status);
|
||||
this.partitionStatuses.set(partition_ref.str, partition_status);
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load status for partition ${partition_ref.str}:`, e);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,8 +363,8 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
|
|||
|
||||
startPolling() {
|
||||
// Use different poll intervals based on build status
|
||||
const isActive = this.data?.status === 'BuildRequestExecuting' ||
|
||||
this.data?.status === 'BuildRequestPlanning';
|
||||
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}`, () => {
|
||||
|
|
@ -349,52 +372,8 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
|
|||
}, interval);
|
||||
},
|
||||
|
||||
|
||||
getEventLink(event: any): { href: string; text: string } | null {
|
||||
switch (event.event_type) {
|
||||
case 'job':
|
||||
if (event.job_label) {
|
||||
return {
|
||||
href: `/jobs/${encodeURIComponent(event.job_label)}`,
|
||||
text: 'Job Details'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
case 'partition':
|
||||
if (event.partition_ref) {
|
||||
return {
|
||||
href: `/partitions/${encodePartitionRef(event.partition_ref)}`,
|
||||
text: 'Partition Status'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
case 'delegation':
|
||||
if (event.delegated_build_id) {
|
||||
return {
|
||||
href: `/builds/${event.delegated_build_id}`,
|
||||
text: 'Delegated Build'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
case 'build_request':
|
||||
// Self-referential, no additional link needed
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
oncreate(vnode: m.VnodeDOM<BuildStatusAttrs>) {
|
||||
(window as any).mermaid.init();
|
||||
},
|
||||
|
||||
onupdate(vnode: m.VnodeDOM<BuildStatusAttrs>) {
|
||||
(window as any).mermaid.init();
|
||||
},
|
||||
|
||||
|
||||
view(vnode: m.Vnode<BuildStatusAttrs>) {
|
||||
// Loading/error states similar to RecentActivity component
|
||||
// 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', [
|
||||
|
|
@ -433,93 +412,123 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
|
|||
|
||||
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.flex.gap-4.items-center.mb-4', [
|
||||
m(BuildStatusBadge, { status: this.data.status, size: 'lg' }),
|
||||
m('.timestamp.text-sm.opacity-70', formatDateTime(this.data.created_at)),
|
||||
m('.partitions.text-sm.opacity-70', `${this.data.requestedPartitions?.length || 0} partitions`),
|
||||
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', [
|
||||
m('.build-graph.card.bg-base-100.shadow-xl', [
|
||||
m('.card-body', [
|
||||
m('h2.card-title.text-xl.mb-4', 'Build Graph'),
|
||||
m('div#build-graph', [
|
||||
m("pre.mermaid", this.data.mermaid_diagram),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
// 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'),
|
||||
m('.partition-grid.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-3',
|
||||
this.data.requested_partitions?.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(status.last_updated)) : null
|
||||
])
|
||||
]);
|
||||
}) || [m('.text-center.py-8.text-base-content.opacity-60', 'No partitions')]
|
||||
)
|
||||
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', '—')
|
||||
])
|
||||
]);
|
||||
})
|
||||
)
|
||||
])
|
||||
]),
|
||||
|
||||
m('.execution-events.card.bg-base-100.shadow-xl', [
|
||||
|
||||
// Build Summary
|
||||
m('.build-summary.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(event.timestamp)),
|
||||
m('td', [
|
||||
m(EventTypeBadge, { eventType: event.event_type })
|
||||
]),
|
||||
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('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))
|
||||
])
|
||||
])
|
||||
]) :
|
||||
m('.text-center.py-8.text-base-content.opacity-60', 'No events')
|
||||
].filter(Boolean))
|
||||
])
|
||||
])
|
||||
])
|
||||
])
|
||||
|
|
@ -528,10 +537,11 @@ export const BuildStatus: TypedComponent<BuildStatusAttrs> = {
|
|||
};
|
||||
|
||||
export const PartitionsList: TypedComponent<PartitionsListAttrs> = {
|
||||
data: null as any | null,
|
||||
data: [] as DashboardPartition[],
|
||||
loading: true,
|
||||
error: null as string | null,
|
||||
searchTerm: '',
|
||||
totalCount: 0,
|
||||
|
||||
async loadPartitions() {
|
||||
try {
|
||||
|
|
@ -539,11 +549,24 @@ export const PartitionsList: TypedComponent<PartitionsListAttrs> = {
|
|||
this.error = null;
|
||||
m.redraw();
|
||||
|
||||
const { DefaultApi, Configuration } = await import('../client/typescript_generated/src/index');
|
||||
const apiClient = new DefaultApi(new Configuration({ basePath: '' }));
|
||||
// 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;
|
||||
|
||||
const response = await apiClient.apiV1PartitionsGet();
|
||||
this.data = response;
|
||||
// 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) {
|
||||
|
|
@ -556,17 +579,24 @@ export const PartitionsList: TypedComponent<PartitionsListAttrs> = {
|
|||
|
||||
async buildPartition(partitionRef: string) {
|
||||
try {
|
||||
const { DefaultApi, Configuration } = await import('../client/typescript_generated/src/index');
|
||||
const apiClient = new DefaultApi(new Configuration({ basePath: '' }));
|
||||
const response = await fetch('/api/v1/builds', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
partitions: [partitionRef]
|
||||
})
|
||||
});
|
||||
|
||||
const build_request = {
|
||||
partitions: [partitionRef]
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const response = await apiClient.apiV1BuildsPost({ build_request });
|
||||
const result = await response.json();
|
||||
|
||||
// Redirect to build status page
|
||||
m.route.set(`/builds/${response.build_request_id}`);
|
||||
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'}`);
|
||||
|
|
@ -574,13 +604,13 @@ export const PartitionsList: TypedComponent<PartitionsListAttrs> = {
|
|||
},
|
||||
|
||||
filteredPartitions() {
|
||||
if (!this.data?.partitions) return [];
|
||||
if (!this.data) return [];
|
||||
|
||||
if (!this.searchTerm) return this.data.partitions;
|
||||
if (!this.searchTerm) return this.data;
|
||||
|
||||
const search = this.searchTerm.toLowerCase();
|
||||
return this.data.partitions.filter((partition: any) =>
|
||||
partition.partition_ref.toLowerCase().includes(search)
|
||||
return this.data.filter((partition: DashboardPartition) =>
|
||||
partition.partition_ref.str.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
||||
|
|
@ -631,7 +661,7 @@ export const PartitionsList: TypedComponent<PartitionsListAttrs> = {
|
|||
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('.badge.badge-primary.badge-lg', `${this.totalCount} total` || "missing")
|
||||
]),
|
||||
|
||||
m('div.form-control.mb-4', [
|
||||
|
|
@ -671,33 +701,35 @@ export const PartitionsList: TypedComponent<PartitionsListAttrs> = {
|
|||
])
|
||||
]),
|
||||
m('tbody',
|
||||
filteredPartitions.map((partition: any) =>
|
||||
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)}`,
|
||||
href: `/partitions/${encodePartitionRef(partition.partition_ref.str)}`,
|
||||
onclick: (e: Event) => {
|
||||
e.preventDefault();
|
||||
m.route.set(`/partitions/${encodePartitionRef(partition.partition_ref)}`);
|
||||
m.route.set(`/partitions/${encodePartitionRef(partition.partition_ref.str)}`);
|
||||
},
|
||||
title: partition.partition_ref
|
||||
}, partition.partition_ref)
|
||||
title: partition.partition_ref.str
|
||||
}, partition.partition_ref.str)
|
||||
]),
|
||||
m('td', [
|
||||
m(PartitionStatusBadge, { status: partition.status })
|
||||
m(PartitionStatusBadge, { status: partition.status_name })
|
||||
]),
|
||||
m('td.text-sm.opacity-70', formatTime(partition.updated_at)),
|
||||
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)
|
||||
onclick: () => this.buildPartition(partition.partition_ref.str)
|
||||
}, 'Build'),
|
||||
partition.build_request_id ?
|
||||
partition.build_requests.length > 0 ?
|
||||
m('a.btn.btn-sm.btn-outline.ml-2', {
|
||||
href: `/builds/${partition.build_request_id}`,
|
||||
href: `/builds/${partition.build_requests[0]}`,
|
||||
onclick: (e: Event) => {
|
||||
e.preventDefault();
|
||||
m.route.set(`/builds/${partition.build_request_id}`);
|
||||
}
|
||||
m.route.set(`/builds/${partition.build_requests[0]}`);
|
||||
},
|
||||
title: 'View most recent build'
|
||||
}, 'View Build') : null
|
||||
])
|
||||
])
|
||||
|
|
@ -713,12 +745,12 @@ export const PartitionsList: TypedComponent<PartitionsListAttrs> = {
|
|||
};
|
||||
|
||||
export const PartitionStatus: TypedComponent<PartitionStatusAttrs> = {
|
||||
data: null as any | null,
|
||||
events: null as any | null,
|
||||
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[],
|
||||
buildHistory: [] as any[], // Keep as any since this is extracted from events
|
||||
|
||||
async loadPartition() {
|
||||
try {
|
||||
|
|
@ -726,23 +758,27 @@ export const PartitionStatus: TypedComponent<PartitionStatusAttrs> = {
|
|||
this.error = null;
|
||||
m.redraw();
|
||||
|
||||
const { DefaultApi, Configuration } = await import('../client/typescript_generated/src/index');
|
||||
const apiClient = new DefaultApi(new Configuration({ basePath: '' }));
|
||||
const service = DashboardService.getInstance();
|
||||
|
||||
// Load partition status
|
||||
const statusResponse = await apiClient.apiV1PartitionsPartitionRefStatusGet({
|
||||
partition_ref: this.partitionRef
|
||||
});
|
||||
this.data = statusResponse;
|
||||
// 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
|
||||
const eventsResponse = await apiClient.apiV1PartitionsPartitionRefEventsGet({
|
||||
partition_ref: this.partitionRef
|
||||
});
|
||||
this.events = eventsResponse;
|
||||
|
||||
// Create build history from events
|
||||
this.buildHistory = this.extractBuildHistory(eventsResponse.events);
|
||||
// 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();
|
||||
|
|
@ -776,15 +812,15 @@ export const PartitionStatus: TypedComponent<PartitionStatusAttrs> = {
|
|||
// Update status based on event type
|
||||
if (event.eventType === 'build_request') {
|
||||
if (event.message?.includes('completed') || event.message?.includes('successful')) {
|
||||
build.status = 'Completed';
|
||||
build.status_name = 'Completed';
|
||||
build.completedAt = event.timestamp;
|
||||
} else if (event.message?.includes('failed') || event.message?.includes('error')) {
|
||||
build.status = 'Failed';
|
||||
build.status_name = 'Failed';
|
||||
build.completedAt = event.timestamp;
|
||||
} else if (event.message?.includes('executing') || event.message?.includes('running')) {
|
||||
build.status = 'Executing';
|
||||
build.status_name = 'Executing';
|
||||
} else if (event.message?.includes('planning')) {
|
||||
build.status = 'Planning';
|
||||
build.status_name = 'Planning';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -796,17 +832,24 @@ export const PartitionStatus: TypedComponent<PartitionStatusAttrs> = {
|
|||
|
||||
async buildPartition(forceRebuild: boolean = false) {
|
||||
try {
|
||||
const { DefaultApi, Configuration } = await import('../client/typescript_generated/src/index');
|
||||
const apiClient = new DefaultApi(new Configuration({ basePath: '' }));
|
||||
const response = await fetch('/api/v1/builds', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
partitions: [this.partitionRef]
|
||||
})
|
||||
});
|
||||
|
||||
const build_request = {
|
||||
partitions: [this.partitionRef]
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const response = await apiClient.apiV1BuildsPost({ build_request });
|
||||
const result = await response.json();
|
||||
|
||||
// Redirect to build status page
|
||||
m.route.set(`/builds/${response.build_request_id}`);
|
||||
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'}`);
|
||||
|
|
@ -876,10 +919,10 @@ export const PartitionStatus: TypedComponent<PartitionStatusAttrs> = {
|
|||
]),
|
||||
|
||||
m('div.partition-meta.flex.gap-4.items-center.mb-4', [
|
||||
m(PartitionStatusBadge, { status: this.data?.status || 'Unknown', size: 'lg' }),
|
||||
this.data?.lastUpdated ?
|
||||
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.lastUpdated)}`) : null,
|
||||
`Last updated: ${formatDateTime(this.data.last_updated)}`) : null,
|
||||
])
|
||||
]),
|
||||
|
||||
|
|
@ -915,7 +958,7 @@ export const PartitionStatus: TypedComponent<PartitionStatusAttrs> = {
|
|||
}, build.id)
|
||||
]),
|
||||
m('td', [
|
||||
m(BuildStatusBadge, { status: build.status })
|
||||
m(BuildStatusBadge, { status: build.status_name })
|
||||
]),
|
||||
m('td.text-sm.opacity-70',
|
||||
formatDateTime(build.startedAt)),
|
||||
|
|
@ -933,12 +976,12 @@ export const PartitionStatus: TypedComponent<PartitionStatusAttrs> = {
|
|||
]),
|
||||
|
||||
// Related Build Requests
|
||||
this.data?.buildRequests && this.data.buildRequests.length > 0 ?
|
||||
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.buildRequests.map((buildId: string) =>
|
||||
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}`,
|
||||
|
|
@ -1003,7 +1046,7 @@ export const PartitionStatus: TypedComponent<PartitionStatusAttrs> = {
|
|||
};
|
||||
|
||||
export const JobsList: TypedComponent<JobsListAttrs> = {
|
||||
jobs: [] as any[],
|
||||
jobs: [] as DashboardJob[],
|
||||
searchTerm: '',
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
|
@ -1034,7 +1077,7 @@ export const JobsList: TypedComponent<JobsListAttrs> = {
|
|||
return JobsList.jobs;
|
||||
}
|
||||
const search = JobsList.searchTerm.toLowerCase();
|
||||
return JobsList.jobs.filter((job: any) =>
|
||||
return JobsList.jobs.filter((job: DashboardJob) =>
|
||||
job.job_label.toLowerCase().includes(search)
|
||||
);
|
||||
},
|
||||
|
|
@ -1092,13 +1135,15 @@ export const JobsList: TypedComponent<JobsListAttrs> = {
|
|||
m('tr', [
|
||||
m('th', 'Job Label'),
|
||||
m('th', 'Success Rate'),
|
||||
m('th', 'Avg Duration'),
|
||||
m('th', 'Recent Runs'),
|
||||
m('th', 'Success/Total'),
|
||||
m('th', 'Avg Partitions'),
|
||||
m('th', 'Last Run'),
|
||||
])
|
||||
]),
|
||||
m('tbody', JobsList.filteredJobs().map((job: any) =>
|
||||
m('tr.hover', [
|
||||
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)}`,
|
||||
|
|
@ -1109,15 +1154,15 @@ export const JobsList: TypedComponent<JobsListAttrs> = {
|
|||
}, 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(`span.badge.${successRate >= 0.9 ? 'badge-success' : successRate >= 0.7 ? 'badge-warning' : 'badge-error'}`,
|
||||
`${Math.round(successRate * 100)}%`)
|
||||
]),
|
||||
m('td', formatDuration(job.avg_duration_ms)),
|
||||
m('td', (job.recent_runs || 0).toString()),
|
||||
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 ? formatTime(job.last_run) : '—'),
|
||||
])
|
||||
))
|
||||
job.last_run_timestamp ? formatTime(job.last_run_timestamp) : '—'),
|
||||
]);
|
||||
}))
|
||||
])
|
||||
])
|
||||
])
|
||||
|
|
@ -1128,7 +1173,7 @@ export const JobsList: TypedComponent<JobsListAttrs> = {
|
|||
|
||||
export const JobMetrics: TypedComponent<JobMetricsAttrs> = {
|
||||
jobLabel: '',
|
||||
metrics: null as any,
|
||||
metrics: null as DashboardJob | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
|
|
@ -1184,6 +1229,9 @@ export const JobMetrics: TypedComponent<JobMetricsAttrs> = {
|
|||
]);
|
||||
}
|
||||
|
||||
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', [
|
||||
|
|
@ -1191,102 +1239,84 @@ export const JobMetrics: TypedComponent<JobMetricsAttrs> = {
|
|||
'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('.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.${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(`span.${successRate >= 0.9 ? 'text-success' : successRate >= 0.7 ? 'text-warning' : 'text-error'}`,
|
||||
`${Math.round(successRate * 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-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', [
|
||||
// Performance Trends
|
||||
JobMetrics.metrics.daily_stats?.length > 0 && m('.performance-trends.card.bg-base-100.shadow-xl', [
|
||||
// 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', '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),
|
||||
])
|
||||
))
|
||||
])
|
||||
])
|
||||
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`)
|
||||
])
|
||||
]),
|
||||
|
||||
// Recent Runs
|
||||
m('.recent-runs.card.bg-base-100.shadow-xl', [
|
||||
// Job Summary Stats
|
||||
m('.job-summary.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(run.started_at)),
|
||||
])
|
||||
))
|
||||
])
|
||||
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')
|
||||
])
|
||||
])
|
||||
])
|
||||
])
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,5 +1,30 @@
|
|||
// Import the generated TypeScript client
|
||||
import { DefaultApi, Configuration, ActivityApiResponse, ActivityResponse, BuildSummary, PartitionSummary, JobsListApiResponse, JobMetricsResponse, JobSummary, JobRunSummary, JobDailyStats } from '../client/typescript_generated/src/index';
|
||||
import {
|
||||
DefaultApi,
|
||||
Configuration,
|
||||
ActivityApiResponse,
|
||||
ActivityResponse,
|
||||
BuildSummary,
|
||||
BuildDetailResponse,
|
||||
PartitionSummary,
|
||||
JobsListApiResponse,
|
||||
JobMetricsResponse,
|
||||
JobSummary,
|
||||
JobRunSummary,
|
||||
JobDailyStats
|
||||
} from '../client/typescript_generated/src/index';
|
||||
|
||||
// Import our dashboard types
|
||||
import {
|
||||
DashboardActivity,
|
||||
DashboardBuild,
|
||||
DashboardPartition,
|
||||
DashboardJob,
|
||||
isDashboardActivity,
|
||||
isDashboardBuild,
|
||||
isDashboardPartition,
|
||||
isDashboardJob
|
||||
} from './types';
|
||||
|
||||
// Configure the API client
|
||||
const apiConfig = new Configuration({
|
||||
|
|
@ -7,28 +32,106 @@ const apiConfig = new Configuration({
|
|||
});
|
||||
const apiClient = new DefaultApi(apiConfig);
|
||||
|
||||
// Types for dashboard data - using the generated API types
|
||||
export interface BuildRequest {
|
||||
buildRequestId: string;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
// Transformation functions: Convert API responses to dashboard types
|
||||
// These functions prevent runtime errors by ensuring consistent data shapes
|
||||
|
||||
function transformBuildSummary(apiResponse: BuildSummary): DashboardBuild {
|
||||
return {
|
||||
build_request_id: apiResponse.build_request_id,
|
||||
status_code: apiResponse.status_code,
|
||||
status_name: apiResponse.status_name,
|
||||
requested_partitions: apiResponse.requested_partitions, // Keep as PartitionRef array
|
||||
total_jobs: apiResponse.total_jobs,
|
||||
completed_jobs: apiResponse.completed_jobs,
|
||||
failed_jobs: apiResponse.failed_jobs,
|
||||
cancelled_jobs: apiResponse.cancelled_jobs,
|
||||
requested_at: apiResponse.requested_at,
|
||||
started_at: apiResponse.started_at ?? null,
|
||||
completed_at: apiResponse.completed_at ?? null,
|
||||
duration_ms: apiResponse.duration_ms ?? null,
|
||||
cancelled: apiResponse.cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PartitionBuild {
|
||||
ref: string;
|
||||
status: string;
|
||||
updatedAt: number;
|
||||
buildRequestId?: string;
|
||||
function transformBuildDetail(apiResponse: BuildDetailResponse): DashboardBuild {
|
||||
return {
|
||||
build_request_id: apiResponse.build_request_id,
|
||||
status_code: apiResponse.status_code,
|
||||
status_name: apiResponse.status_name,
|
||||
requested_partitions: apiResponse.requested_partitions, // Keep as PartitionRef array
|
||||
total_jobs: apiResponse.total_jobs,
|
||||
completed_jobs: apiResponse.completed_jobs,
|
||||
failed_jobs: apiResponse.failed_jobs,
|
||||
cancelled_jobs: apiResponse.cancelled_jobs,
|
||||
requested_at: apiResponse.requested_at,
|
||||
started_at: apiResponse.started_at ?? null,
|
||||
completed_at: apiResponse.completed_at ?? null,
|
||||
duration_ms: apiResponse.duration_ms ?? null,
|
||||
cancelled: apiResponse.cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecentActivitySummary {
|
||||
activeBuilds: number;
|
||||
recentBuilds: BuildRequest[];
|
||||
recentPartitions: PartitionBuild[];
|
||||
totalPartitions: number;
|
||||
systemStatus: string;
|
||||
graphName: string;
|
||||
function transformPartitionSummary(apiResponse: PartitionSummary): DashboardPartition {
|
||||
if (!apiResponse.partition_ref) {
|
||||
throw new Error('PartitionSummary must have a valid partition_ref');
|
||||
}
|
||||
|
||||
return {
|
||||
partition_ref: apiResponse.partition_ref, // Keep as PartitionRef object
|
||||
status_code: apiResponse.status_code,
|
||||
status_name: apiResponse.status_name,
|
||||
last_updated: apiResponse.last_updated ?? null,
|
||||
build_requests: (apiResponse as any).build_requests || [], // This field might not be in the OpenAPI spec
|
||||
};
|
||||
}
|
||||
|
||||
function transformJobSummary(apiResponse: JobSummary): DashboardJob {
|
||||
return {
|
||||
job_label: apiResponse.job_label,
|
||||
total_runs: apiResponse.total_runs,
|
||||
successful_runs: apiResponse.successful_runs,
|
||||
failed_runs: apiResponse.failed_runs,
|
||||
cancelled_runs: apiResponse.cancelled_runs,
|
||||
last_run_timestamp: apiResponse.last_run_timestamp,
|
||||
last_run_status_code: apiResponse.last_run_status_code,
|
||||
last_run_status_name: apiResponse.last_run_status_name,
|
||||
average_partitions_per_run: apiResponse.average_partitions_per_run,
|
||||
recent_builds: apiResponse.recent_builds || [], // Default for optional array field
|
||||
};
|
||||
}
|
||||
|
||||
function transformActivityResponse(apiResponse: ActivityResponse): DashboardActivity {
|
||||
return {
|
||||
active_builds_count: apiResponse.active_builds_count,
|
||||
recent_builds: apiResponse.recent_builds.map(transformBuildSummary),
|
||||
recent_partitions: apiResponse.recent_partitions.map(transformPartitionSummary),
|
||||
total_partitions_count: apiResponse.total_partitions_count,
|
||||
system_status: apiResponse.system_status,
|
||||
graph_name: apiResponse.graph_name,
|
||||
};
|
||||
}
|
||||
|
||||
// Type guards for runtime validation
|
||||
function isValidBuildDetailResponse(data: unknown): data is BuildDetailResponse {
|
||||
return typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'build_request_id' in data &&
|
||||
'status_name' in data &&
|
||||
'requested_partitions' in data;
|
||||
}
|
||||
|
||||
function isValidActivityResponse(data: unknown): data is ActivityResponse {
|
||||
return typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'active_builds_count' in data &&
|
||||
'recent_builds' in data &&
|
||||
'recent_partitions' in data;
|
||||
}
|
||||
|
||||
function isValidJobsListApiResponse(data: unknown): data is JobsListApiResponse {
|
||||
return typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'data' in data;
|
||||
}
|
||||
|
||||
// API Service for fetching recent activity data
|
||||
|
|
@ -42,7 +145,7 @@ export class DashboardService {
|
|||
return DashboardService.instance;
|
||||
}
|
||||
|
||||
async getRecentActivity(): Promise<RecentActivitySummary> {
|
||||
async getRecentActivity(): Promise<DashboardActivity> {
|
||||
try {
|
||||
// Use the new activity endpoint that aggregates all the data we need
|
||||
const activityApiResponse: ActivityApiResponse = await apiClient.apiV1ActivityGet();
|
||||
|
|
@ -50,45 +153,36 @@ export class DashboardService {
|
|||
|
||||
const activityResponse = activityApiResponse.data;
|
||||
|
||||
// Convert the API response to our dashboard format
|
||||
const recentBuilds: BuildRequest[] = activityResponse.recent_builds.map((build: BuildSummary) => ({
|
||||
buildRequestId: build.build_request_id,
|
||||
status: build.status_name, // Use human-readable status name
|
||||
createdAt: build.requested_at,
|
||||
updatedAt: build.started_at || build.requested_at,
|
||||
}));
|
||||
// Validate API response structure
|
||||
if (!isValidActivityResponse(activityResponse)) {
|
||||
throw new Error('Invalid activity response structure');
|
||||
}
|
||||
|
||||
const recentPartitions: PartitionBuild[] = activityResponse.recent_partitions.map((partition: PartitionSummary) => ({
|
||||
ref: partition.partition_ref,
|
||||
status: partition.status_name, // Use human-readable status name
|
||||
updatedAt: partition.last_updated,
|
||||
buildRequestId: partition.last_successful_build || undefined
|
||||
}));
|
||||
console.info("made", recentBuilds, recentPartitions);
|
||||
return {
|
||||
activeBuilds: activityResponse.active_builds_count,
|
||||
recentBuilds,
|
||||
recentPartitions,
|
||||
totalPartitions: activityResponse.total_partitions_count,
|
||||
systemStatus: activityResponse.system_status,
|
||||
graphName: activityResponse.graph_name
|
||||
};
|
||||
// Transform API response to dashboard format using transformation function
|
||||
const dashboardActivity = transformActivityResponse(activityResponse);
|
||||
|
||||
// Validate transformed result
|
||||
if (!isDashboardActivity(dashboardActivity)) {
|
||||
throw new Error('Transformation produced invalid dashboard activity');
|
||||
}
|
||||
|
||||
return dashboardActivity;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent activity:', error);
|
||||
|
||||
// Fall back to mock data if API call fails
|
||||
// Fall back to valid dashboard format if API call fails
|
||||
return {
|
||||
activeBuilds: 0,
|
||||
recentBuilds: [],
|
||||
recentPartitions: [],
|
||||
totalPartitions: 0,
|
||||
systemStatus: 'error',
|
||||
graphName: 'Unknown Graph'
|
||||
active_builds_count: 0,
|
||||
recent_builds: [],
|
||||
recent_partitions: [],
|
||||
total_partitions_count: 0,
|
||||
system_status: 'error',
|
||||
graph_name: 'Unknown Graph'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getJobs(searchTerm?: string): Promise<JobSummary[]> {
|
||||
async getJobs(searchTerm?: string): Promise<DashboardJob[]> {
|
||||
try {
|
||||
// Build query parameters manually since the generated client may not support query params correctly
|
||||
const queryParams = new URLSearchParams();
|
||||
|
|
@ -101,15 +195,98 @@ export class DashboardService {
|
|||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const data: JobsListApiResponse = await response.json();
|
||||
return data.data.jobs;
|
||||
const data: unknown = await response.json();
|
||||
|
||||
// Validate API response structure
|
||||
if (!isValidJobsListApiResponse(data)) {
|
||||
throw new Error('Invalid jobs list response structure');
|
||||
}
|
||||
|
||||
// Transform each job using our transformation function
|
||||
const dashboardJobs = data.data.jobs.map(transformJobSummary);
|
||||
|
||||
// Validate each transformed job
|
||||
for (const job of dashboardJobs) {
|
||||
if (!isDashboardJob(job)) {
|
||||
throw new Error('Transformation produced invalid dashboard job');
|
||||
}
|
||||
}
|
||||
|
||||
return dashboardJobs;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch jobs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getJobMetrics(jobLabel: string): Promise<JobMetricsResponse | null> {
|
||||
async getBuildDetail(buildId: string): Promise<DashboardBuild | null> {
|
||||
try {
|
||||
const url = `/api/v1/builds/${buildId}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null; // Build not found
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const data: unknown = await response.json();
|
||||
|
||||
// Validate API response structure
|
||||
if (!isValidBuildDetailResponse(data)) {
|
||||
throw new Error('Invalid build detail response structure');
|
||||
}
|
||||
|
||||
// Transform to dashboard format
|
||||
const dashboardBuild = transformBuildDetail(data);
|
||||
|
||||
// Validate transformed result
|
||||
if (!isDashboardBuild(dashboardBuild)) {
|
||||
throw new Error('Transformation produced invalid dashboard build');
|
||||
}
|
||||
|
||||
return dashboardBuild;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch build detail:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getPartitionDetail(partitionRef: string): Promise<DashboardPartition | null> {
|
||||
try {
|
||||
// Encode partition ref for URL safety
|
||||
const encodedRef = btoa(partitionRef).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
const url = `/api/v1/partitions/${encodedRef}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null; // Partition not found
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const data: unknown = await response.json();
|
||||
|
||||
// For partition detail, we need to extract the PartitionSummary from the response
|
||||
// and transform it to dashboard format
|
||||
if (typeof data === 'object' && data !== null && 'partition_ref' in data) {
|
||||
const dashboardPartition = transformPartitionSummary(data as PartitionSummary);
|
||||
|
||||
if (!isDashboardPartition(dashboardPartition)) {
|
||||
throw new Error('Transformation produced invalid dashboard partition');
|
||||
}
|
||||
|
||||
return dashboardPartition;
|
||||
} else {
|
||||
throw new Error('Invalid partition detail response structure');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch partition detail:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getJobMetrics(jobLabel: string): Promise<DashboardJob | null> {
|
||||
try {
|
||||
// Encode job label like partition refs for URL safety
|
||||
const encodedLabel = btoa(jobLabel).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
|
|
@ -122,8 +299,22 @@ export class DashboardService {
|
|||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const data: JobMetricsResponse = await response.json();
|
||||
return data;
|
||||
const data: unknown = await response.json();
|
||||
console.log('Job metrics response:', data);
|
||||
|
||||
// Extract job summary from metrics response and transform it
|
||||
if (typeof data === 'object' && data !== null && 'job_label' in data) {
|
||||
const dashboardJob = transformJobSummary(data as unknown as JobSummary);
|
||||
console.log('Transformed job summary:', dashboardJob);
|
||||
|
||||
if (!isDashboardJob(dashboardJob)) {
|
||||
throw new Error('Transformation produced invalid dashboard job');
|
||||
}
|
||||
|
||||
return dashboardJob;
|
||||
}
|
||||
|
||||
throw new Error('Invalid job metrics response structure');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch job metrics:', error);
|
||||
return null;
|
||||
|
|
|
|||
44
databuild/dashboard/test-data/strict-config-failures.ts
Normal file
44
databuild/dashboard/test-data/strict-config-failures.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Test file designed to fail TypeScript compilation with strict config
|
||||
// These are the exact patterns that caused runtime failures in production
|
||||
|
||||
// Test 1: Reproduce original status.toLowerCase() failure
|
||||
const mockResponseWithStatusObject = { status_code: 1, status_name: "COMPLETED" };
|
||||
|
||||
// This should cause compilation error: Property 'status' does not exist
|
||||
const test1 = mockResponseWithStatusObject.status?.toLowerCase();
|
||||
|
||||
// Test 2: Reproduce original status?.status access failure
|
||||
const test2 = mockResponseWithStatusObject.status?.status;
|
||||
|
||||
// Test 3: Optional field access without null check
|
||||
interface PartitionSummaryTest {
|
||||
last_updated?: number;
|
||||
partition_ref: string;
|
||||
}
|
||||
|
||||
const testPartition: PartitionSummaryTest = {
|
||||
partition_ref: "test-partition"
|
||||
};
|
||||
|
||||
// This should fail: accessing optional field without null check
|
||||
const timestamp = testPartition.last_updated.toString();
|
||||
|
||||
// Test 4: Exact optional property types
|
||||
interface StrictTest {
|
||||
required: string;
|
||||
optional?: string;
|
||||
}
|
||||
|
||||
// This should fail with exactOptionalPropertyTypes
|
||||
const testObj: StrictTest = {
|
||||
required: "test",
|
||||
optional: undefined // undefined not assignable to optional string
|
||||
};
|
||||
|
||||
// Test 5: Array access without undefined handling
|
||||
const testArray: string[] = ["a", "b", "c"];
|
||||
const element: string = testArray[10]; // Should include undefined in type
|
||||
|
||||
// Test 6: Null access without proper checks
|
||||
let possiblyNull: string | null = Math.random() > 0.5 ? "value" : null;
|
||||
const upperCase = possiblyNull.toUpperCase(); // Should fail with strictNullChecks
|
||||
69
databuild/dashboard/test-strict-config.sh
Executable file
69
databuild/dashboard/test-strict-config.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
#!/bin/bash
|
||||
# Test script to verify strict TypeScript configuration catches expected failures
|
||||
|
||||
set -e
|
||||
|
||||
echo "Testing strict TypeScript configuration..."
|
||||
|
||||
# Find TypeScript compiler in runfiles
|
||||
if [[ -n "${RUNFILES_DIR:-}" ]]; then
|
||||
TSC="${RUNFILES_DIR}/_main/databuild/dashboard/node_modules/typescript/bin/tsc"
|
||||
else
|
||||
# Fallback for local execution
|
||||
TSC="$(find . -name tsc -type f | head -1)"
|
||||
if [[ -z "$TSC" ]]; then
|
||||
echo "ERROR: Could not find TypeScript compiler"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get paths relative to runfiles
|
||||
if [[ -n "${RUNFILES_DIR:-}" ]]; then
|
||||
TEST_DATA_DIR="${RUNFILES_DIR}/_main/databuild/dashboard/test-data"
|
||||
TSCONFIG="${RUNFILES_DIR}/_main/databuild/dashboard/tsconfig_app.json"
|
||||
else
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TEST_DATA_DIR="$SCRIPT_DIR/test-data"
|
||||
TSCONFIG="$SCRIPT_DIR/tsconfig_app.json"
|
||||
fi
|
||||
|
||||
# Function to test that TypeScript compilation fails with expected errors
|
||||
test_compilation_failures() {
|
||||
local test_file="$1"
|
||||
local expected_errors="$2"
|
||||
|
||||
echo "Testing compilation failures for: $test_file"
|
||||
|
||||
# Run TypeScript compilation and capture output
|
||||
if node "$TSC" --noEmit --strict --strictNullChecks --noImplicitAny --noImplicitReturns --noUncheckedIndexedAccess --exactOptionalPropertyTypes "$test_file" 2>&1; then
|
||||
echo "ERROR: Expected TypeScript compilation to fail for $test_file, but it passed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check that we get the expected error patterns
|
||||
local tsc_output=$(node "$TSC" --noEmit --strict --strictNullChecks --noImplicitAny --noImplicitReturns --noUncheckedIndexedAccess --exactOptionalPropertyTypes "$test_file" 2>&1 || true)
|
||||
|
||||
IFS='|' read -ra ERROR_PATTERNS <<< "$expected_errors"
|
||||
for pattern in "${ERROR_PATTERNS[@]}"; do
|
||||
if ! echo "$tsc_output" | grep -q "$pattern"; then
|
||||
echo "ERROR: Expected error pattern '$pattern' not found in TypeScript output"
|
||||
echo "Actual output:"
|
||||
echo "$tsc_output"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✓ Compilation correctly failed with expected errors"
|
||||
}
|
||||
|
||||
# Test 1: Verify strict config catches undefined property access
|
||||
test_compilation_failures "$TEST_DATA_DIR/strict-config-failures.ts" "Property 'status' does not exist|is possibly 'undefined'|Type 'undefined' is not assignable"
|
||||
|
||||
echo "All strict TypeScript configuration tests passed!"
|
||||
echo ""
|
||||
echo "Summary of what strict config catches:"
|
||||
echo "- ✓ Undefined property access (status.toLowerCase() failures)"
|
||||
echo "- ✓ Optional field access without null checks"
|
||||
echo "- ✓ Exact optional property type mismatches"
|
||||
echo "- ✓ Array access without undefined handling"
|
||||
echo "- ✓ Null/undefined access without proper checks"
|
||||
320
databuild/dashboard/transformation-tests.ts
Normal file
320
databuild/dashboard/transformation-tests.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
// Phase 3.5: Unit tests for transformation functions
|
||||
// These tests verify that transformation functions prevent the observed runtime failures
|
||||
|
||||
import o from 'ospec';
|
||||
import {
|
||||
BuildSummary,
|
||||
BuildDetailResponse,
|
||||
PartitionSummary,
|
||||
JobSummary,
|
||||
ActivityResponse
|
||||
} from '../client/typescript_generated/src/index';
|
||||
|
||||
// Import types directly since we're now in the same ts_project
|
||||
import {
|
||||
DashboardActivity,
|
||||
DashboardBuild,
|
||||
DashboardPartition,
|
||||
DashboardJob,
|
||||
isDashboardActivity,
|
||||
isDashboardBuild,
|
||||
isDashboardPartition,
|
||||
isDashboardJob
|
||||
} from './types';
|
||||
|
||||
// Mock transformation functions for testing (since they're not exported from services.ts)
|
||||
function transformBuildSummary(apiResponse: BuildSummary): DashboardBuild {
|
||||
return {
|
||||
build_request_id: apiResponse.build_request_id,
|
||||
status_code: apiResponse.status_code,
|
||||
status_name: apiResponse.status_name,
|
||||
requested_partitions: apiResponse.requested_partitions, // Keep as PartitionRef array
|
||||
total_jobs: apiResponse.total_jobs,
|
||||
completed_jobs: apiResponse.completed_jobs,
|
||||
failed_jobs: apiResponse.failed_jobs,
|
||||
cancelled_jobs: apiResponse.cancelled_jobs,
|
||||
requested_at: apiResponse.requested_at,
|
||||
started_at: apiResponse.started_at ?? null,
|
||||
completed_at: apiResponse.completed_at ?? null,
|
||||
duration_ms: apiResponse.duration_ms ?? null,
|
||||
cancelled: apiResponse.cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
function transformBuildDetail(apiResponse: BuildDetailResponse): DashboardBuild {
|
||||
return {
|
||||
build_request_id: apiResponse.build_request_id,
|
||||
status_code: apiResponse.status_code,
|
||||
status_name: apiResponse.status_name,
|
||||
requested_partitions: apiResponse.requested_partitions, // Keep as PartitionRef array
|
||||
total_jobs: apiResponse.total_jobs,
|
||||
completed_jobs: apiResponse.completed_jobs,
|
||||
failed_jobs: apiResponse.failed_jobs,
|
||||
cancelled_jobs: apiResponse.cancelled_jobs,
|
||||
requested_at: apiResponse.requested_at,
|
||||
started_at: apiResponse.started_at ?? null,
|
||||
completed_at: apiResponse.completed_at ?? null,
|
||||
duration_ms: apiResponse.duration_ms ?? null,
|
||||
cancelled: apiResponse.cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
function transformPartitionSummary(apiResponse: any): DashboardPartition {
|
||||
return {
|
||||
partition_ref: apiResponse.partition_ref, // Keep as PartitionRef object
|
||||
status_code: apiResponse.status_code,
|
||||
status_name: apiResponse.status_name,
|
||||
last_updated: apiResponse.last_updated ?? null,
|
||||
build_requests: apiResponse.build_requests || [],
|
||||
};
|
||||
}
|
||||
|
||||
function transformJobSummary(apiResponse: JobSummary): DashboardJob {
|
||||
return {
|
||||
job_label: apiResponse.job_label,
|
||||
total_runs: apiResponse.total_runs,
|
||||
successful_runs: apiResponse.successful_runs,
|
||||
failed_runs: apiResponse.failed_runs,
|
||||
cancelled_runs: apiResponse.cancelled_runs,
|
||||
last_run_timestamp: apiResponse.last_run_timestamp,
|
||||
last_run_status_code: apiResponse.last_run_status_code,
|
||||
last_run_status_name: apiResponse.last_run_status_name,
|
||||
average_partitions_per_run: apiResponse.average_partitions_per_run,
|
||||
recent_builds: apiResponse.recent_builds || [],
|
||||
};
|
||||
}
|
||||
|
||||
function transformActivityResponse(apiResponse: ActivityResponse): DashboardActivity {
|
||||
return {
|
||||
active_builds_count: apiResponse.active_builds_count,
|
||||
recent_builds: apiResponse.recent_builds.map(transformBuildSummary),
|
||||
recent_partitions: apiResponse.recent_partitions.map(transformPartitionSummary),
|
||||
total_partitions_count: apiResponse.total_partitions_count,
|
||||
system_status: apiResponse.system_status,
|
||||
graph_name: apiResponse.graph_name,
|
||||
};
|
||||
}
|
||||
|
||||
// Test Data Mocks
|
||||
const mockBuildSummary: BuildSummary = {
|
||||
build_request_id: 'build-123',
|
||||
status_code: 4, // BUILD_REQUEST_COMPLETED
|
||||
status_name: 'COMPLETED',
|
||||
requested_partitions: [{ str: 'partition-1' }, { str: 'partition-2' }],
|
||||
total_jobs: 5,
|
||||
completed_jobs: 5,
|
||||
failed_jobs: 0,
|
||||
cancelled_jobs: 0,
|
||||
requested_at: 1640995200000000000, // 2022-01-01 00:00:00 UTC in nanos
|
||||
started_at: 1640995260000000000, // 2022-01-01 00:01:00 UTC in nanos
|
||||
completed_at: 1640995320000000000, // 2022-01-01 00:02:00 UTC in nanos
|
||||
duration_ms: 60000, // 1 minute
|
||||
cancelled: false
|
||||
};
|
||||
|
||||
const mockPartitionSummary: any = {
|
||||
partition_ref: { str: 'test-partition' },
|
||||
status_code: 4, // PARTITION_AVAILABLE
|
||||
status_name: 'AVAILABLE',
|
||||
last_updated: 1640995200000000000,
|
||||
builds_count: 3,
|
||||
invalidation_count: 0,
|
||||
build_requests: ['build-123', 'build-124'],
|
||||
last_successful_build: 'build-123'
|
||||
};
|
||||
|
||||
const mockJobSummary: JobSummary = {
|
||||
job_label: '//:test-job',
|
||||
total_runs: 10,
|
||||
successful_runs: 9,
|
||||
failed_runs: 1,
|
||||
cancelled_runs: 0,
|
||||
average_partitions_per_run: 2.5,
|
||||
last_run_timestamp: 1640995200000000000,
|
||||
last_run_status_code: 3, // JOB_COMPLETED
|
||||
last_run_status_name: 'COMPLETED',
|
||||
recent_builds: ['build-123', 'build-124']
|
||||
};
|
||||
|
||||
const mockActivityResponse: ActivityResponse = {
|
||||
active_builds_count: 2,
|
||||
recent_builds: [mockBuildSummary],
|
||||
recent_partitions: [mockPartitionSummary],
|
||||
total_partitions_count: 100,
|
||||
system_status: 'healthy',
|
||||
graph_name: 'test-graph'
|
||||
};
|
||||
|
||||
// Test Suite
|
||||
o.spec('Transformation Functions', () => {
|
||||
o('transformBuildSummary handles status fields correctly', () => {
|
||||
const result = transformBuildSummary(mockBuildSummary);
|
||||
|
||||
// The key fix: status_name should be a string, status_code a number
|
||||
o(typeof result.status_code).equals('number');
|
||||
o(typeof result.status_name).equals('string');
|
||||
o(result.status_name).equals('COMPLETED');
|
||||
|
||||
// This should not throw (preventing the original runtime error)
|
||||
o(() => result.status_name.toLowerCase()).notThrows('status_name.toLowerCase should work');
|
||||
});
|
||||
|
||||
o('transformBuildSummary handles null optional fields', () => {
|
||||
const buildWithNulls: BuildSummary = {
|
||||
...mockBuildSummary,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
duration_ms: null
|
||||
};
|
||||
|
||||
const result = transformBuildSummary(buildWithNulls);
|
||||
|
||||
// Explicit null handling prevents undefined property access
|
||||
o(result.started_at).equals(null);
|
||||
o(result.completed_at).equals(null);
|
||||
o(result.duration_ms).equals(null);
|
||||
});
|
||||
|
||||
o('transformPartitionSummary preserves PartitionRef objects correctly', () => {
|
||||
const result = transformPartitionSummary(mockPartitionSummary);
|
||||
|
||||
// The key fix: partition_ref should remain as PartitionRef object
|
||||
o(typeof result.partition_ref).equals('object');
|
||||
o(result.partition_ref.str).equals('test-partition');
|
||||
|
||||
// This should not throw (preventing original runtime errors)
|
||||
o(() => result.partition_ref.str.toLowerCase()).notThrows('partition_ref.str.toLowerCase should work');
|
||||
});
|
||||
|
||||
o('transformPartitionSummary handles missing arrays safely', () => {
|
||||
const partitionWithoutArray: any = {
|
||||
...mockPartitionSummary
|
||||
};
|
||||
delete partitionWithoutArray.build_requests;
|
||||
|
||||
const result = transformPartitionSummary(partitionWithoutArray);
|
||||
|
||||
// Should default to empty array, preventing length/iteration errors
|
||||
o(Array.isArray(result.build_requests)).equals(true);
|
||||
o(result.build_requests.length).equals(0);
|
||||
});
|
||||
|
||||
o('transformJobSummary handles status fields correctly', () => {
|
||||
const result = transformJobSummary(mockJobSummary);
|
||||
|
||||
// The key fix: both status code and name should be preserved
|
||||
o(typeof result.last_run_status_code).equals('number');
|
||||
o(typeof result.last_run_status_name).equals('string');
|
||||
o(result.last_run_status_name).equals('COMPLETED');
|
||||
|
||||
// This should not throw
|
||||
o(() => result.last_run_status_name.toLowerCase()).notThrows('last_run_status_name.toLowerCase should work');
|
||||
});
|
||||
|
||||
o('transformActivityResponse maintains structure consistency', () => {
|
||||
const result = transformActivityResponse(mockActivityResponse);
|
||||
|
||||
// Should pass our type guard
|
||||
o(isDashboardActivity(result)).equals(true);
|
||||
|
||||
// All nested objects should be properly transformed
|
||||
o(result.recent_builds.length).equals(1);
|
||||
o(typeof result.recent_builds[0]?.status_name).equals('string');
|
||||
|
||||
o(result.recent_partitions.length).equals(1);
|
||||
o(typeof result.recent_partitions[0]?.partition_ref).equals('object');
|
||||
o(typeof result.recent_partitions[0]?.partition_ref.str).equals('string');
|
||||
});
|
||||
|
||||
o('transformations prevent original runtime failures', () => {
|
||||
const result = transformActivityResponse(mockActivityResponse);
|
||||
|
||||
// These are the exact patterns that caused runtime failures:
|
||||
|
||||
// 1. status_name.toLowerCase() - should not crash
|
||||
result.recent_builds.forEach((build: DashboardBuild) => {
|
||||
o(() => build.status_name.toLowerCase()).notThrows('build.status_name.toLowerCase should work');
|
||||
o(build.status_name.toLowerCase()).equals('completed');
|
||||
});
|
||||
|
||||
// 2. partition_ref.str access - should access string property
|
||||
result.recent_partitions.forEach((partition: DashboardPartition) => {
|
||||
o(typeof partition.partition_ref).equals('object');
|
||||
o(typeof partition.partition_ref.str).equals('string');
|
||||
o(() => partition.partition_ref.str.toLowerCase()).notThrows('partition.partition_ref.str.toLowerCase should work');
|
||||
});
|
||||
|
||||
// 3. Null/undefined handling - should be explicit
|
||||
result.recent_builds.forEach((build: DashboardBuild) => {
|
||||
// These fields can be null but never undefined
|
||||
o(build.started_at === null || typeof build.started_at === 'number').equals(true);
|
||||
o(build.completed_at === null || typeof build.completed_at === 'number').equals(true);
|
||||
o(build.duration_ms === null || typeof build.duration_ms === 'number').equals(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Edge Cases and Error Conditions
|
||||
o.spec('Transformation Edge Cases', () => {
|
||||
o('handles empty arrays correctly', () => {
|
||||
const emptyActivity: ActivityResponse = {
|
||||
...mockActivityResponse,
|
||||
recent_builds: [],
|
||||
recent_partitions: []
|
||||
};
|
||||
|
||||
const result = transformActivityResponse(emptyActivity);
|
||||
|
||||
o(Array.isArray(result.recent_builds)).equals(true);
|
||||
o(result.recent_builds.length).equals(0);
|
||||
o(Array.isArray(result.recent_partitions)).equals(true);
|
||||
o(result.recent_partitions.length).equals(0);
|
||||
});
|
||||
|
||||
o('handles malformed PartitionRef gracefully', () => {
|
||||
const malformedPartition: any = {
|
||||
...mockPartitionSummary,
|
||||
partition_ref: { str: '' } // Empty string
|
||||
};
|
||||
|
||||
const result = transformPartitionSummary(malformedPartition);
|
||||
|
||||
o(typeof result.partition_ref.str).equals('string');
|
||||
o(result.partition_ref.str).equals('');
|
||||
});
|
||||
|
||||
o('transformations produce valid dashboard types', () => {
|
||||
// Test that all transformation results pass type guards
|
||||
const transformedBuild = transformBuildSummary(mockBuildSummary);
|
||||
const transformedPartition = transformPartitionSummary(mockPartitionSummary);
|
||||
const transformedJob = transformJobSummary(mockJobSummary);
|
||||
const transformedActivity = transformActivityResponse(mockActivityResponse);
|
||||
|
||||
o(isDashboardBuild(transformedBuild)).equals(true);
|
||||
o(isDashboardPartition(transformedPartition)).equals(true);
|
||||
o(isDashboardJob(transformedJob)).equals(true);
|
||||
o(isDashboardActivity(transformedActivity)).equals(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Performance and Memory Tests
|
||||
o.spec('Transformation Performance', () => {
|
||||
o('transforms large datasets efficiently', () => {
|
||||
const largeActivity: ActivityResponse = {
|
||||
...mockActivityResponse,
|
||||
recent_builds: Array(1000).fill(mockBuildSummary),
|
||||
recent_partitions: Array(1000).fill(mockPartitionSummary)
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
const result = transformActivityResponse(largeActivity);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Should complete transformation in reasonable time
|
||||
o(duration < 1000).equals(true); // Less than 1 second
|
||||
o(result.recent_builds.length).equals(1000);
|
||||
o(result.recent_partitions.length).equals(1000);
|
||||
});
|
||||
});
|
||||
|
||||
// Export default removed - tests are run by importing this file
|
||||
|
|
@ -12,6 +12,10 @@
|
|||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
"strictNullChecks": true, /* Enable error reporting for null and undefined values. */
|
||||
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return. */
|
||||
"noUncheckedIndexedAccess": true, /* Add 'undefined' to index signature results. */
|
||||
"exactOptionalPropertyTypes": true, /* Ensure optional property types are exact. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
"rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
"baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
"resolveJsonModule": true, /* Enable importing .json files. */
|
||||
"allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
"inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
|
|
@ -12,6 +13,10 @@
|
|||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
"strictNullChecks": true, /* Enable error reporting for null and undefined values. */
|
||||
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return. */
|
||||
"noUncheckedIndexedAccess": true, /* Add 'undefined' to index signature results. */
|
||||
"exactOptionalPropertyTypes": true, /* Ensure optional property types are exact. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,78 @@ import {
|
|||
JobSummary,
|
||||
JobMetricsResponse,
|
||||
JobDailyStats,
|
||||
JobRunSummary
|
||||
JobRunSummary,
|
||||
PartitionRef
|
||||
} from '../client/typescript_generated/src/index';
|
||||
|
||||
// Dashboard-optimized types - canonical frontend types independent of backend schema
|
||||
// These types prevent runtime errors by ensuring consistent data shapes throughout components
|
||||
|
||||
export interface DashboardBuild {
|
||||
build_request_id: string;
|
||||
status_code: number;
|
||||
status_name: string;
|
||||
requested_partitions: PartitionRef[];
|
||||
total_jobs: number;
|
||||
completed_jobs: number;
|
||||
failed_jobs: number;
|
||||
cancelled_jobs: number;
|
||||
requested_at: number;
|
||||
started_at: number | null;
|
||||
completed_at: number | null;
|
||||
duration_ms: number | null;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardPartition {
|
||||
partition_ref: PartitionRef;
|
||||
status_code: number;
|
||||
status_name: string;
|
||||
last_updated: number | null;
|
||||
build_requests: string[];
|
||||
}
|
||||
|
||||
export interface DashboardJob {
|
||||
job_label: string;
|
||||
total_runs: number;
|
||||
successful_runs: number;
|
||||
failed_runs: number;
|
||||
cancelled_runs: number;
|
||||
last_run_timestamp: number;
|
||||
last_run_status_code: number;
|
||||
last_run_status_name: string;
|
||||
average_partitions_per_run: number;
|
||||
recent_builds: string[];
|
||||
}
|
||||
|
||||
export interface DashboardActivity {
|
||||
active_builds_count: number;
|
||||
recent_builds: DashboardBuild[];
|
||||
recent_partitions: DashboardPartition[];
|
||||
total_partitions_count: number;
|
||||
system_status: string;
|
||||
graph_name: string;
|
||||
}
|
||||
|
||||
// Dashboard timeline event types for consistent UI handling
|
||||
export interface DashboardBuildTimelineEvent {
|
||||
timestamp: number;
|
||||
status_code: number;
|
||||
status_name: string;
|
||||
message: string;
|
||||
event_type: string;
|
||||
cancel_reason?: string;
|
||||
}
|
||||
|
||||
export interface DashboardPartitionTimelineEvent {
|
||||
timestamp: number;
|
||||
status_code: number;
|
||||
status_name: string;
|
||||
message: string;
|
||||
build_request_id: string;
|
||||
job_run_id?: string;
|
||||
}
|
||||
|
||||
// Generic typed component interface that extends Mithril's component
|
||||
// Uses intersection type to allow arbitrary properties while ensuring type safety for lifecycle methods
|
||||
export interface TypedComponent<TAttrs = {}> extends Record<string, any> {
|
||||
|
|
@ -102,32 +171,33 @@ export interface LayoutWrapperAttrs {
|
|||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Data types for component state (using OpenAPI types)
|
||||
// Data types for component state (using Dashboard types for consistency)
|
||||
export interface RecentActivityData {
|
||||
data: ActivityResponse | null;
|
||||
data: DashboardActivity | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BuildStatusData {
|
||||
data: BuildDetailResponse | null;
|
||||
partitionStatuses: Map<string, any>;
|
||||
data: DashboardBuild | null;
|
||||
partitionStatuses: Map<string, DashboardPartition>; // Key is partition_ref.str
|
||||
timeline: DashboardBuildTimelineEvent[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
buildId: string;
|
||||
}
|
||||
|
||||
export interface PartitionStatusData {
|
||||
data: PartitionDetailResponse | null;
|
||||
events: PartitionEventsResponse | null;
|
||||
data: DashboardPartition | null;
|
||||
timeline: DashboardPartitionTimelineEvent[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
partitionRef: string;
|
||||
buildHistory: any[];
|
||||
buildHistory: DashboardBuild[];
|
||||
}
|
||||
|
||||
export interface JobsListData {
|
||||
jobs: JobSummary[];
|
||||
jobs: DashboardJob[];
|
||||
searchTerm: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
|
@ -136,7 +206,7 @@ export interface JobsListData {
|
|||
|
||||
export interface JobMetricsData {
|
||||
jobLabel: string;
|
||||
metrics: JobMetricsResponse | null;
|
||||
job: DashboardJob | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
|
@ -144,8 +214,29 @@ export interface JobMetricsData {
|
|||
// Utility type for creating typed components
|
||||
export type CreateTypedComponent<TAttrs> = TypedComponent<TAttrs>;
|
||||
|
||||
// Type guards and validators using OpenAPI type information
|
||||
export function isActivityResponse(data: any): data is ActivityResponse {
|
||||
/*
|
||||
## Dashboard Type Transformation Rationale
|
||||
|
||||
The dashboard types provide a stable interface between the OpenAPI-generated types and UI components:
|
||||
|
||||
1. **Explicit Null Handling**: Protobuf optional fields become `T | null` instead of `T | undefined`
|
||||
to ensure consistent null checking throughout the application.
|
||||
|
||||
2. **Type Safety**: Keep protobuf structure (PartitionRef objects, status codes) to maintain
|
||||
type safety from backend to frontend. Only convert to display strings in components.
|
||||
|
||||
3. **Clear Boundaries**: Dashboard types are the contract between services and components.
|
||||
Services handle API responses, components handle presentation.
|
||||
|
||||
Key principles:
|
||||
- Preserve protobuf structure for type safety
|
||||
- Explicit null handling for optional fields
|
||||
- Convert to display strings only at the UI layer
|
||||
- Consistent types prevent runtime errors
|
||||
*/
|
||||
|
||||
// Type guards and validators for Dashboard types
|
||||
export function isDashboardActivity(data: any): data is DashboardActivity {
|
||||
return data &&
|
||||
typeof data.active_builds_count === 'number' &&
|
||||
typeof data.graph_name === 'string' &&
|
||||
|
|
@ -155,17 +246,32 @@ export function isActivityResponse(data: any): data is ActivityResponse {
|
|||
typeof data.total_partitions_count === 'number';
|
||||
}
|
||||
|
||||
export function isBuildSummary(data: any): data is BuildSummary {
|
||||
export function isDashboardBuild(data: any): data is DashboardBuild {
|
||||
return data &&
|
||||
typeof data.build_request_id === 'string' &&
|
||||
typeof data.status_code === 'number' &&
|
||||
typeof data.status_name === 'string' &&
|
||||
typeof data.requested_at === 'number';
|
||||
typeof data.requested_at === 'number' &&
|
||||
Array.isArray(data.requested_partitions);
|
||||
}
|
||||
|
||||
export function isPartitionSummary(data: any): data is PartitionSummary {
|
||||
export function isDashboardPartition(data: any): data is DashboardPartition {
|
||||
return data &&
|
||||
typeof data.partition_ref === 'string' &&
|
||||
typeof data.last_updated === 'number';
|
||||
data.partition_ref &&
|
||||
typeof data.partition_ref.str === 'string' &&
|
||||
typeof data.status_code === 'number' &&
|
||||
typeof data.status_name === 'string' &&
|
||||
(data.last_updated === null || typeof data.last_updated === 'number') &&
|
||||
Array.isArray(data.build_requests);
|
||||
}
|
||||
|
||||
export function isDashboardJob(data: any): data is DashboardJob {
|
||||
return data &&
|
||||
typeof data.job_label === 'string' &&
|
||||
typeof data.total_runs === 'number' &&
|
||||
typeof data.last_run_status_code === 'number' &&
|
||||
typeof data.last_run_status_name === 'string' &&
|
||||
Array.isArray(data.recent_builds);
|
||||
}
|
||||
|
||||
// Helper function to create type-safe Mithril components
|
||||
|
|
|
|||
|
|
@ -294,7 +294,7 @@ message PartitionsListResponse {
|
|||
}
|
||||
|
||||
message PartitionSummary {
|
||||
string partition_ref = 1;
|
||||
PartitionRef partition_ref = 1;
|
||||
PartitionStatus status_code = 2; // Enum for programmatic use
|
||||
string status_name = 3; // Human-readable string
|
||||
int64 last_updated = 4;
|
||||
|
|
@ -447,11 +447,11 @@ message BuildTimelineEvent {
|
|||
//
|
||||
|
||||
message PartitionDetailRequest {
|
||||
string partition_ref = 1;
|
||||
PartitionRef partition_ref = 1;
|
||||
}
|
||||
|
||||
message PartitionDetailResponse {
|
||||
string partition_ref = 1;
|
||||
PartitionRef partition_ref = 1;
|
||||
PartitionStatus status_code = 2; // Enum for programmatic use
|
||||
string status_name = 3; // Human-readable string
|
||||
int64 last_updated = 4;
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ mod format_consistency_tests {
|
|||
|
||||
// Test PartitionSummary creation
|
||||
let summary = create_partition_summary(
|
||||
"test/partition".to_string(),
|
||||
PartitionRef { str: "test/partition".to_string() },
|
||||
PartitionStatus::PartitionAvailable,
|
||||
1234567890,
|
||||
5,
|
||||
|
|
@ -131,7 +131,7 @@ mod format_consistency_tests {
|
|||
Some("build-123".to_string()),
|
||||
);
|
||||
|
||||
assert_eq!(summary.partition_ref, "test/partition");
|
||||
assert_eq!(summary.partition_ref, Some(PartitionRef { str: "test/partition".to_string() }));
|
||||
assert_eq!(summary.status_code, 4); // PartitionAvailable = 4
|
||||
assert_eq!(summary.status_name, "available");
|
||||
assert_eq!(summary.last_updated, 1234567890);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub struct PartitionsRepository {
|
|||
/// Summary of a partition's current state and history
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PartitionInfo {
|
||||
pub partition_ref: String,
|
||||
pub partition_ref: PartitionRef,
|
||||
pub current_status: PartitionStatus,
|
||||
pub last_updated: i64,
|
||||
pub builds_count: usize,
|
||||
|
|
@ -120,7 +120,7 @@ impl PartitionsRepository {
|
|||
.count();
|
||||
|
||||
PartitionInfo {
|
||||
partition_ref,
|
||||
partition_ref: PartitionRef { str: partition_ref },
|
||||
current_status,
|
||||
last_updated,
|
||||
builds_count: builds.len(),
|
||||
|
|
@ -217,7 +217,7 @@ impl PartitionsRepository {
|
|||
.map(|e| e.build_request_id.clone());
|
||||
|
||||
let partition_info = PartitionInfo {
|
||||
partition_ref: partition_ref.to_string(),
|
||||
partition_ref: PartitionRef { str: partition_ref.to_string() },
|
||||
current_status,
|
||||
last_updated,
|
||||
builds_count: builds.len(),
|
||||
|
|
@ -269,7 +269,7 @@ impl PartitionsRepository {
|
|||
.collect();
|
||||
|
||||
let response = PartitionDetailResponse {
|
||||
partition_ref: partition_info.partition_ref,
|
||||
partition_ref: Some(partition_info.partition_ref),
|
||||
status_code: partition_info.current_status as i32,
|
||||
status_name: partition_info.current_status.to_display_string(),
|
||||
last_updated: partition_info.last_updated,
|
||||
|
|
@ -356,8 +356,8 @@ mod tests {
|
|||
assert_eq!(partitions.len(), 2);
|
||||
|
||||
// Find partitions by name
|
||||
let users_partition = partitions.iter().find(|p| p.partition_ref == "data/users").unwrap();
|
||||
let orders_partition = partitions.iter().find(|p| p.partition_ref == "data/orders").unwrap();
|
||||
let users_partition = partitions.iter().find(|p| p.partition_ref.str == "data/users").unwrap();
|
||||
let orders_partition = partitions.iter().find(|p| p.partition_ref.str == "data/orders").unwrap();
|
||||
|
||||
assert_eq!(users_partition.current_status, PartitionStatus::PartitionAvailable);
|
||||
assert_eq!(orders_partition.current_status, PartitionStatus::PartitionFailed);
|
||||
|
|
@ -383,7 +383,7 @@ mod tests {
|
|||
assert!(result.is_some());
|
||||
|
||||
let (info, timeline) = result.unwrap();
|
||||
assert_eq!(info.partition_ref, "analytics/metrics");
|
||||
assert_eq!(info.partition_ref.str, "analytics/metrics");
|
||||
assert_eq!(info.current_status, PartitionStatus::PartitionAvailable);
|
||||
assert_eq!(info.builds_count, 1);
|
||||
assert_eq!(timeline.len(), 3);
|
||||
|
|
|
|||
|
|
@ -259,7 +259,9 @@ pub async fn get_partition_events(
|
|||
State(service): State<ServiceState>,
|
||||
Path(PartitionEventsRequest { partition_ref }): Path<PartitionEventsRequest>,
|
||||
) -> Result<Json<PartitionEventsResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let events = match service.event_log.get_partition_events(&partition_ref, None).await {
|
||||
let decoded_partition_ref = base64_url_decode(&partition_ref).unwrap();
|
||||
|
||||
let events = match service.event_log.get_partition_events(&decoded_partition_ref, None).await {
|
||||
Ok(events) => events.into_iter().map(|e| {
|
||||
let (job_label, partition_ref, delegated_build_id) = extract_navigation_data(&e.event_type);
|
||||
BuildEventSummary {
|
||||
|
|
@ -285,7 +287,7 @@ pub async fn get_partition_events(
|
|||
};
|
||||
|
||||
Ok(Json(PartitionEventsResponse {
|
||||
partition_ref,
|
||||
partition_ref: decoded_partition_ref,
|
||||
events,
|
||||
}))
|
||||
}
|
||||
|
|
@ -974,8 +976,9 @@ pub async fn get_partition_detail(
|
|||
Path(PartitionDetailRequest { partition_ref }): Path<PartitionDetailRequest>,
|
||||
) -> Result<Json<PartitionDetailResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let repository = PartitionsRepository::new(service.event_log.clone());
|
||||
let decoded_partition_ref = base64_url_decode(&partition_ref).unwrap();
|
||||
|
||||
match repository.show_protobuf(&partition_ref).await {
|
||||
match repository.show_protobuf(&decoded_partition_ref).await {
|
||||
Ok(Some(protobuf_response)) => {
|
||||
let timeline_events: Vec<PartitionTimelineEvent> = protobuf_response.timeline.into_iter().map(|event| {
|
||||
PartitionTimelineEvent {
|
||||
|
|
@ -1189,7 +1192,7 @@ pub async fn get_job_detail(
|
|||
State(service): State<ServiceState>,
|
||||
Path(JobDetailRequest { label }): Path<JobDetailRequest>,
|
||||
) -> Result<Json<JobDetailResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let job_label = label;
|
||||
let job_label = base64_url_decode(&label).unwrap();
|
||||
let repository = JobsRepository::new(service.event_log.clone());
|
||||
|
||||
match repository.show_protobuf(&job_label).await {
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ pub mod list_response_helpers {
|
|||
|
||||
/// Create a PartitionSummary from repository data
|
||||
pub fn create_partition_summary(
|
||||
partition_ref: String,
|
||||
partition_ref: PartitionRef,
|
||||
status: PartitionStatus,
|
||||
last_updated: i64,
|
||||
builds_count: usize,
|
||||
|
|
@ -125,7 +125,7 @@ pub mod list_response_helpers {
|
|||
last_successful_build: Option<String>,
|
||||
) -> PartitionSummary {
|
||||
PartitionSummary {
|
||||
partition_ref,
|
||||
partition_ref: Some(partition_ref),
|
||||
status_code: status as i32,
|
||||
status_name: status.to_display_string(),
|
||||
last_updated,
|
||||
|
|
|
|||
Loading…
Reference in a new issue