databuild/databuild/dashboard/services.ts
Stuart Axelbrooke dc622dd0ac
Some checks failed
/ setup (push) Has been cancelled
Minor timestamp fix
2025-08-16 16:21:43 -07:00

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'
});
}