492 lines
No EOL
16 KiB
TypeScript
492 lines
No EOL
16 KiB
TypeScript
// Import the generated TypeScript client
|
|
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({
|
|
basePath: '', // Use relative paths since we're on the same host
|
|
});
|
|
const apiClient = new DefaultApi(apiConfig);
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
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: 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
|
|
export class DashboardService {
|
|
private static instance: DashboardService;
|
|
|
|
static getInstance(): DashboardService {
|
|
if (!DashboardService.instance) {
|
|
DashboardService.instance = new DashboardService();
|
|
}
|
|
return DashboardService.instance;
|
|
}
|
|
|
|
async getRecentActivity(): Promise<DashboardActivity> {
|
|
try {
|
|
// Use the new activity endpoint that aggregates all the data we need
|
|
const activityApiResponse: ActivityApiResponse = await apiClient.apiV1ActivityGet();
|
|
console.info('Recent activity:', activityApiResponse);
|
|
|
|
const activityResponse = activityApiResponse.data;
|
|
|
|
// Validate API response structure
|
|
if (!isValidActivityResponse(activityResponse)) {
|
|
throw new Error('Invalid activity response structure');
|
|
}
|
|
|
|
// 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 valid dashboard format if API call fails
|
|
return {
|
|
active_builds_count: 0,
|
|
recent_builds: [],
|
|
recent_partitions: [],
|
|
total_partitions_count: 0,
|
|
system_status: 'error',
|
|
graph_name: 'Unknown Graph'
|
|
};
|
|
}
|
|
}
|
|
|
|
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();
|
|
if (searchTerm) {
|
|
queryParams.append('search', searchTerm);
|
|
}
|
|
const url = `/api/v1/jobs${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
|
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
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 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, '');
|
|
const url = `/api/v1/jobs/${encodedLabel}`;
|
|
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
return null; // Job not found
|
|
}
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
async getMermaidDiagram(buildId: string): Promise<string | null> {
|
|
try {
|
|
const url = `/api/v1/builds/${buildId}/mermaid`;
|
|
|
|
const response = await fetch(url);
|
|
if (!response.ok) {
|
|
if (response.status === 404) {
|
|
return null; // Build not found or no job graph
|
|
}
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Validate response structure
|
|
if (typeof data === 'object' && data !== null && 'mermaid' in data && typeof data.mermaid === 'string') {
|
|
return data.mermaid;
|
|
}
|
|
|
|
throw new Error('Invalid mermaid response structure');
|
|
} catch (error) {
|
|
console.error('Failed to fetch mermaid diagram:', error);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Polling manager with Page Visibility API integration
|
|
export class PollingManager {
|
|
private intervals: Map<string, NodeJS.Timeout> = new Map();
|
|
private isTabVisible: boolean = true;
|
|
private visibilityChangeHandler: () => void;
|
|
|
|
constructor() {
|
|
this.visibilityChangeHandler = () => {
|
|
this.isTabVisible = !document.hidden;
|
|
|
|
// Pause or resume polling based on tab visibility
|
|
if (this.isTabVisible) {
|
|
this.resumePolling();
|
|
} else {
|
|
this.pausePolling();
|
|
}
|
|
};
|
|
|
|
// Set up Page Visibility API listener only in browser environment
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('visibilitychange', this.visibilityChangeHandler);
|
|
}
|
|
}
|
|
|
|
startPolling(key: string, callback: () => void, intervalMs: number): void {
|
|
// Clear existing interval if any
|
|
this.stopPolling(key);
|
|
|
|
// Only start polling if tab is visible
|
|
if (this.isTabVisible) {
|
|
const interval = setInterval(callback, intervalMs);
|
|
this.intervals.set(key, interval);
|
|
}
|
|
}
|
|
|
|
stopPolling(key: string): void {
|
|
const interval = this.intervals.get(key);
|
|
if (interval) {
|
|
clearInterval(interval);
|
|
this.intervals.delete(key);
|
|
}
|
|
}
|
|
|
|
private pausePolling(): void {
|
|
// Store current intervals but clear them
|
|
for (const [key, interval] of this.intervals) {
|
|
clearInterval(interval);
|
|
}
|
|
}
|
|
|
|
private resumePolling(): void {
|
|
// This is a simplified approach - in practice you'd want to store the callback
|
|
// and interval info to properly resume. For now, components will handle this
|
|
// by checking visibility state when setting up polling.
|
|
}
|
|
|
|
cleanup(): void {
|
|
// Clean up all intervals
|
|
for (const interval of this.intervals.values()) {
|
|
clearInterval(interval);
|
|
}
|
|
this.intervals.clear();
|
|
|
|
// Remove event listener only in browser environment
|
|
if (typeof document !== 'undefined') {
|
|
document.removeEventListener('visibilitychange', this.visibilityChangeHandler);
|
|
}
|
|
}
|
|
|
|
isVisible(): boolean {
|
|
return this.isTabVisible;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const pollingManager = new PollingManager();
|
|
|
|
// Utility functions for time formatting
|
|
export function formatTime(epochNanos: number): string {
|
|
const date = new Date(epochNanos / 1000000);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
|
|
if (diffMs < 60000) { // Less than 1 minute
|
|
return 'just now';
|
|
} else if (diffMs < 3600000) { // Less than 1 hour
|
|
const minutes = Math.floor(diffMs / 60000);
|
|
return `${minutes}m ago`;
|
|
} else if (diffMs < 86400000) { // Less than 1 day
|
|
const hours = Math.floor(diffMs / 3600000);
|
|
return `${hours}h ago`;
|
|
} else {
|
|
return date.toLocaleDateString();
|
|
}
|
|
}
|
|
|
|
export function formatDateTime(epochNanos: number): string {
|
|
const date = new Date(epochNanos / 1000000);
|
|
const dateStr = date.toLocaleDateString('en-US');
|
|
const timeStr = date.toLocaleTimeString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: true,
|
|
timeZoneName: 'short'
|
|
});
|
|
const millisStr = date.getMilliseconds().toString().padStart(3, '0');
|
|
|
|
// Insert milliseconds between seconds and AM/PM: "7/12/2025, 9:03:48.264 AM EST"
|
|
return `${dateStr}, ${timeStr.replace(/(\d{2})\s+(AM|PM)/, `$1.${millisStr} $2`)}`;
|
|
}
|
|
|
|
export function formatDuration(durationNanos?: number | null): string {
|
|
let durationMs = durationNanos ? durationNanos / 1000000 : null;
|
|
console.warn('Formatting duration:', durationMs);
|
|
if (!durationMs || durationMs <= 0) {
|
|
return '—';
|
|
}
|
|
|
|
if (durationMs < 1000) {
|
|
return `${Math.round(durationMs)}ms`;
|
|
} else if (durationMs < 60000) {
|
|
return `${(durationMs / 1000).toFixed(1)}s`;
|
|
} else if (durationMs < 3600000) {
|
|
const minutes = Math.floor(durationMs / 60000);
|
|
const seconds = Math.floor((durationMs % 60000) / 1000);
|
|
return `${minutes}m ${seconds}s`;
|
|
} else {
|
|
const hours = Math.floor(durationMs / 3600000);
|
|
const minutes = Math.floor((durationMs % 3600000) / 60000);
|
|
return `${hours}h ${minutes}m`;
|
|
}
|
|
}
|
|
|
|
export function formatDate(epochNanos: number): string {
|
|
const date = new Date(epochNanos / 1000000);
|
|
return date.toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
} |