import * as chalk from 'chalk'; import {command, help, multiple, namespace, option, param} from 'oo-cli'; import * as table from 'text-table'; import {isValidDateOrEpochOrDurationString, parseDate} from '../../lib/parseDate'; import {die} from '../../lib/die'; import {styledStringLength} from '../../lib/StringUtils'; import {Rivendell} from '../../lib/Rivendell'; import {formatError} from '../../lib/formatError'; import {default as parseDuration} from 'parse-duration'; import Job = Rivendell.Job; import JobDefinition = Rivendell.JobDefinition; import Sort = Rivendell.Sort; import SortDirection = Rivendell.SortDirection; import JobStatus = Rivendell.JobStatus; import JobSearchCriteria = Rivendell.JobSearchCriteria; import { formatJobStatus } from '../../lib/formatJobStatus'; import { jobRuntime } from '../../lib/jobRuntime'; interface Column { long: string; header: string; } interface DisplayJob extends Job { runtime?: number; duration?: string; } const COLUMN_DEFINITIONS: Column[] = [ {long: 'id', header: 'Job ID'}, {long: 'version', header: 'Version'}, {long: 'function', header: 'Job Function'}, {long: 'trackerId', header: 'Tracker ID'}, {long: 'trigger', header: 'Trigger'}, {long: 'status', header: 'Status'}, {long: 'error', header: 'Error'}, {long: 'createdAt', header: 'Created At'}, {long: 'triggeredAt', header: 'Triggered At'}, {long: 'scheduledAt', header: 'Scheduled At'}, {long: 'terminatedAt', header: 'Terminated At'}, {long: 'updatedAt', header: 'Updated At'}, {long: 'duration', header: 'Duration'} ]; const SORT_COLUMNS: Column[] = COLUMN_DEFINITIONS .filter((c) => !['appId', 'trigger', 'version'].includes(c.long)); const DEFAULT_DISPLAY_COLUMNS = COLUMN_DEFINITIONS .filter( (c) => ['id', 'version', 'function', 'trackerId', 'status', 'createdAt', 'updatedAt', 'duration'].includes(c.long) ); const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; function columnLongString(columns: Column[]) { return columns.map((x) => x.long).join(', '); } @namespace('jobs') export class ListCommand { @param @help('Specific App ID to filter by') private appId!: string; @option @help('Specific Versions to filter by') @multiple private version?: string[]; @option @help('Specific Tracker IDs to filter by') @multiple private trackerId?: string[]; @option @help('Specific Job Function to filter by') @multiple private function?: string[]; @option @help(`Specific JobStatus to filter by. Possible values: ${Object.values(JobStatus).join(', ')}`) @multiple private status?: JobStatus[]; @option @help('Show jobs that have been running longer than the given duration. (e.g. 5m = 5 minutes)') private minDuration?: string; @option @help(`Column to sort the jobs by. Possible values: ${columnLongString(SORT_COLUMNS)}; Default: updatedAt`) private sortBy: string = 'updatedAt'; @option @help('Sort resulting jobs in ascending or descending order. Possible values: asc|desc. Default: desc') private sortDirection: string = 'desc'; @option @help('Number of jobs to list. Default: 50') private limit?: number = 50; @option @help(`Columns to display. Possible values: ${columnLongString(COLUMN_DEFINITIONS)}. Default: ${columnLongString(DEFAULT_DISPLAY_COLUMNS)}`) private columns: string = DEFAULT_DISPLAY_COLUMNS.map((c) => c.long).join(','); @option @help('A start time as an ISO string, an epoch timestamp, or relative string (i.e. "5m" for 5 minutes.) Default: 7d') private from?: string; @option @help('A end time as an ISO string, an epoch timestamp, or relative string (i.e. "5m" for 5 minutes)') private to?: string; @option('a') @help('The availability zone that will be targeted (default: us)') private availability: string = ''; @command @help('List jobs') public async list(): Promise { try { const criteria: JobSearchCriteria = { appId: this.appId, }; if (this.version) { criteria.versions = this.version; } if (this.trackerId) { criteria.trackerIds = this.trackerId; } if (this.status) { criteria.states = this.getJobStatus(this.status); } if (this.function) { criteria.functions = this.function; } if (this.minDuration) { const parsedDurationInMillis = parseDuration(this.minDuration, 'ms'); if (!parsedDurationInMillis) { die(chalk.red('Invalid "--minDuration" input, should be a relative string (i.e. "5m" for 5 minutes.')); return; } criteria.minDurationMillis = parsedDurationInMillis; } if (this.limit) { criteria.limit = this.limit; } criteria.sort = [this.getSort(this.sortBy, this.sortDirection)]; if (this.from) { if (!isValidDateOrEpochOrDurationString(this.from)) { die(chalk.red('Invalid "--from" input, should be an ISO string,' + 'an epoch timestamp, or relative string (i.e. "5m" for 5 minutes.')); } criteria.start = parseDate(this.from)?.valueOf(); } if (this.to) { if (!isValidDateOrEpochOrDurationString(this.to)) { die(chalk.red('Invalid "--to" input, should be an ISO string,' + 'an epoch timestamp, or relative string (i.e. "5m" for 5 minutes.')); } criteria.end = parseDate(this.to)?.valueOf(); } const thirtyDaysAgo = Date.now() - THIRTY_DAYS; if (criteria.start && criteria.start < thirtyDaysAgo) { console.log( chalk.yellow(`Warning: history can go no more than 30 days back. Showing jobs started after ${new Date(thirtyDaysAgo)}`) ); criteria.start = thirtyDaysAgo; } const jobs = await Rivendell.searchJobs(criteria, this.availability); if (jobs.length === 0) { die(chalk.yellow(`No jobs found since ${this.from} ago for given criteria. Try to change the criteria or extend the time range.`)); } else { this.render(this.amendWithDuration(jobs)); } } catch (e: any) { die(formatError(e)); } } private getJobStatus(status: string[]): JobStatus[] { return status.map((s) => { const key = s.toUpperCase(); if (!JobStatus[key as JobStatus]) { die(`${s} is not a valid JobStatus`); } return JobStatus[key as JobStatus]; }); } private getSort(sortBy: string, sortDirection: string | undefined) { const sortColumn = SORT_COLUMNS.find((c) => c.long === sortBy); if (!sortColumn) { die(`"${sortBy}" not an available column for sorting`); } const sort: Sort = { field: sortColumn!.long }; if (sortDirection) { if (sortDirection.toUpperCase() === SortDirection.ASC) { sort.direction = SortDirection.ASC; } else if (sortDirection.toUpperCase() === SortDirection.DESC) { sort.direction = SortDirection.DESC; } else { die(`"${sortDirection}" not valid for sorting`); } } return sort; } private amendWithDuration(jobs: Job[]) { const jobsWithRuntime: DisplayJob[] = jobs.map((job) => { const runtime = jobRuntime(job); return {...job, runtime, duration: this.timeToDurationString(runtime)}; }); return jobsWithRuntime; } private timeToDurationString(ms: number | undefined) { if (!ms || ms <= 0) { return '-'; } const duration = ms / 1000; const hours = Math.floor(duration / 3600); const minutes = Math.floor((duration - (hours * 3600)) / 60); const seconds = duration - (hours * 3600) - (minutes * 60); const out = []; if (hours > 0) { out.push(`${hours}h`); } if (minutes > 0) { out.push(`${minutes}m`); } if (seconds > 0) { out.push(`${seconds}s`); } return out.join(' '); } private render(jobs: DisplayJob[]) { const columns: Column[] = this.columns.split(',') .map((c) => { const col = COLUMN_DEFINITIONS.find((d) => d.long === c); if (!col) { die(`"${c}" is not a valid column name`); } return col!; }); const header = columns.map((item) => chalk.grey(item.header)); const rows = jobs.map((job: DisplayJob) => { return columns.map((col) => { if (job[col.long as keyof Job]) { if (col.long === 'status') { return formatJobStatus(job[col.long as keyof Job] as JobStatus); } return job[col.long as keyof Job]; } else if (job.definition[col.long as keyof JobDefinition]) { return job.definition[col.long as keyof JobDefinition]; } else { return ''; } }); }); const t = table( [ header, ...rows ], { hsep: ' '.repeat(8), stringLength: styledStringLength } ); console.log(`\n${t}\n`); if (Number(this.limit) === jobs.length) { console.log(chalk.yellow(`Showing first ${this.limit} results. Use --limit paramter to see more.\n`)); } } }