Use standard visuals table component for fleet/s

This effectively removes the ability to filter/sort/customize the output table.
The cli cannot properly handle this operations on all models and this one was inconsistent.
For now we recommend that users that require parsing the CLI output use the output json format and do any kind of necessary parsing on it.

Change-type: major
This commit is contained in:
Otavio Jacobi 2024-07-17 11:39:25 -03:00
parent a08ac447a3
commit 63674c8201
8 changed files with 43 additions and 512 deletions

View File

@ -2168,13 +2168,9 @@ fleet name or slug (preferred)
open fleet dashboard page
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
produce JSON output instead of tabular output
## fleet pin <slug> [releaseToPinTo]
@ -2353,29 +2349,9 @@ Examples:
### Options
#### --fields FIELDS
only show provided fields (comma-separated)
#### -j, --json
output in json format
#### --filter FILTER
filter results by substring matching of a given field, eg: --filter field=foo
#### --no-header
hide table header from output
#### --no-truncate
do not truncate output to fit screen
#### --sort SORT
field to sort by (prepend '-' for descending order)
produce JSON output instead of tabular output
# Local

View File

@ -21,7 +21,6 @@ import {
NotAvailableInOfflineModeError,
} from './errors';
import { stripIndent } from './utils/lazy';
import * as output from './framework/output';
export default abstract class BalenaCommand extends Command {
/**
@ -168,7 +167,4 @@ export default abstract class BalenaCommand extends Command {
await this.getStdin();
}
}
protected outputMessage = output.outputMessage;
protected outputData = output.outputData;
}

View File

@ -20,7 +20,7 @@ import { Flags } from '@oclif/core';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import * as ca from '../../utils/common-args';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
import { applicationIdInfo } from '../../utils/messages';
export default class FleetCmd extends Command {
@ -49,7 +49,7 @@ export default class FleetCmd extends Command {
default: false,
description: 'open fleet dashboard page',
}),
...cf.dataOutputFlags,
json: cf.json,
};
public static authenticated = true;
@ -78,16 +78,28 @@ export default class FleetCmd extends Command {
return;
}
const outputApplication = {
...application,
const applicationToDisplay = {
id: application.id,
app_name: application.app_name,
slug: application.slug,
device_type: application.is_for__device_type[0].slug,
commit: application.should_be_running__release[0]?.commit,
};
await this.outputData(
outputApplication,
['app_name', 'id', 'device_type', 'slug', 'commit'],
options,
if (options.json) {
console.log(JSON.stringify(applicationToDisplay, null, 4));
return;
}
// Emulate table.vertical title output, but avoid uppercasing and inserting spaces
console.log(`== ${applicationToDisplay.app_name}`);
console.log(
getVisuals().table.vertical(applicationToDisplay, [
'id',
'device_type',
'slug',
'commit',
]),
);
}
}

View File

@ -19,7 +19,7 @@ import type * as BalenaSdk from 'balena-sdk';
import Command from '../../command';
import * as cf from '../../utils/common-flags';
import { getBalenaSdk, stripIndent } from '../../utils/lazy';
import { getBalenaSdk, getVisuals, stripIndent } from '../../utils/lazy';
interface ExtendedApplication extends ApplicationWithDeviceTypeSlug {
device_count: number;
@ -42,8 +42,8 @@ export default class FleetsCmd extends Command {
public static usage = 'fleets';
public static flags = {
...cf.dataSetOutputFlags,
help: cf.help,
json: cf.json,
};
public static authenticated = true;
@ -77,17 +77,29 @@ export default class FleetsCmd extends Command {
application.device_type = application.is_for__device_type[0].slug;
});
await this.outputData(
applications,
[
const applicationsToDisplay = applications.map((application) => ({
id: application.id,
app_name: application.app_name,
slug: application.slug,
device_type: application.device_type,
online_devices: application.online_devices,
device_count: application.device_count,
}));
if (options.json) {
console.log(JSON.stringify(applicationsToDisplay, null, 4));
return;
}
console.log(
getVisuals().table.horizontal(applicationsToDisplay, [
'id',
'app_name',
'app_name => NAME',
'slug',
'device_type',
'device_count',
'online_devices',
],
options,
'device_count',
]),
);
}
}

View File

@ -1,19 +0,0 @@
/*
Copyright 2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { DataOutputOptions, DataSetOutputOptions } from './output';
export { DataOutputOptions, DataSetOutputOptions };

View File

@ -1,158 +0,0 @@
/*
Copyright 2020 Balena
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { getCliUx, getChalk } from '../utils/lazy';
export interface DataOutputOptions {
fields?: string;
json?: boolean;
}
export interface DataSetOutputOptions extends DataOutputOptions {
filter?: string;
'no-header'?: boolean;
'no-truncate'?: boolean;
sort?: string;
}
/**
* Output message to STDERR
*/
export function outputMessage(msg: string) {
// Messages go to STDERR
console.error(msg);
}
/**
* Output result data to STDOUT
* Supports:
* - arrays of items (displayed in a tabular way),
* - single items (displayed in a field per row format).
*
* @param data Array of data objects to output
* @param fields Array of fieldnames, specifying the fields and display order
* @param options Output options
*/
export async function outputData(
data: any[] | object,
fields: string[],
options: DataOutputOptions | DataSetOutputOptions,
) {
if (Array.isArray(data)) {
await outputDataSet(data, fields, options as DataSetOutputOptions);
} else {
await outputDataItem(data, fields, options as DataOutputOptions);
}
}
/**
* Wraps the cli.ux table implementation, to output tabular data
*
* @param data Array of data objects to output
* @param fields Array of fieldnames, specifying the fields and display order
* @param options Output options
*/
async function outputDataSet(
data: any[],
fields: string[],
options: DataSetOutputOptions,
) {
// Oclif expects fields to be specified in the format used in table headers (though lowercase)
// By replacing underscores with spaces here, we can support both header format and actual field name
// (e.g. as seen in json output).
options.fields = options.fields?.replace(/_/g, ' ');
options.filter = options.filter?.replace(/_/g, ' ');
options.sort = options.sort?.replace(/_/g, ' ');
getCliUx().table(
data,
// Convert fields array to column object keys
// that cli.ux expects. We can later add support
// for both formats if beneficial
fields.reduce((ac, a) => ({ ...ac, [a]: {} }), {}),
{
...options,
...(options.json
? {
output: 'json',
}
: {}),
columns: options.fields,
printLine,
},
);
}
/**
* Outputs a single data object (like `resin-cli-visuals table.vertical`),
* but supporting a subset of options from `cli-ux table` (--json and --fields)
*
* @param data Array of data objects to output
* @param fields Array of fieldnames, specifying the fields and display order
* @param options Output options
*/
async function outputDataItem(
data: any,
fields: string[],
options: DataOutputOptions,
) {
const outData: typeof data = {};
// Convert comma separated list of fields in `options.fields` to array of correct format.
// Note, user may have specified the true field name (e.g. `some_field`),
// or the format displayed in headers (e.g. `Some field`, case insensitive).
const userSelectedFields = options.fields?.split(',').map((f) => {
return f.toLowerCase().trim().replace(/ /g, '_');
});
// Order and filter the fields based on `fields` parameter and `options.fields`
(userSelectedFields || fields).forEach((fieldName) => {
if (fields.includes(fieldName)) {
outData[fieldName] = data[fieldName];
}
});
if (options.json) {
printLine(JSON.stringify(outData, undefined, 2));
} else {
const chalk = getChalk();
const { capitalize } = await import('lodash');
// Find longest key, so we can align results
const longestKeyLength = getLongestObjectKeyLength(outData);
// Output one field per line
for (const [k, v] of Object.entries(outData)) {
const shim = ' '.repeat(longestKeyLength - k.length);
const kDisplay = capitalize(k.replace(/_/g, ' '));
printLine(`${chalk.bold(kDisplay) + shim} : ${v}`);
}
}
}
function getLongestObjectKeyLength(o: any): number {
return Object.keys(o).length >= 1
? Object.keys(o).reduce((a, b) => {
return a.length > b.length ? a : b;
}).length
: 0;
}
function printLine(s: any) {
// Duplicating oclif cli-ux's default implementation here,
// but using this one explicitly for ease of testing
process.stdout.write(s + '\n');
}

View File

@ -104,36 +104,3 @@ export const json = Flags.boolean({
description: 'produce JSON output instead of tabular output',
default: false,
});
export const dataOutputFlags = {
fields: Flags.string({
description: 'only show provided fields (comma-separated)',
}),
json: Flags.boolean({
char: 'j',
exclusive: ['no-truncate'],
description: 'output in json format',
default: false,
}),
};
export const dataSetOutputFlags = {
...dataOutputFlags,
filter: Flags.string({
description:
'filter results by substring matching of a given field, eg: --filter field=foo',
}),
'no-header': Flags.boolean({
exclusive: ['json'],
description: 'hide table header from output',
default: false,
}),
'no-truncate': Flags.boolean({
exclusive: ['json'],
description: 'do not truncate output to fit screen',
default: false,
}),
sort: Flags.string({
description: `field to sort by (prepend '-' for descending order)`,
}),
};

View File

@ -1,255 +0,0 @@
/**
* @license
* Copyright 2020-2021 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* tslint:disable: prefer-const no-empty */
import rewire = require('rewire');
import sinon = require('sinon');
import { expect } from 'chai';
const dataItem = {
name: 'item1',
id: 1,
thing_color: 'blue',
thing_shape: 'square',
};
const dataSet = [
{
name: 'item1',
id: 1,
thing_color: 'red',
thing_shape: 'square',
},
{
name: 'item2',
id: 2,
thing_color: 'blue',
thing_shape: 'round',
},
];
describe('outputData', function () {
let outputData: any;
let outputDataSetSpy: any;
let outputDataItemSpy: any;
this.beforeEach(() => {
const output = rewire('../../build/framework/output');
outputDataSetSpy = sinon.spy();
outputDataItemSpy = sinon.spy();
output.__set__('outputDataSet', outputDataSetSpy);
output.__set__('outputDataItem', outputDataItemSpy);
outputData = output.__get__('outputData');
});
it('should call outputDataSet function when data param is an array', async () => {
await outputData(dataSet);
expect(outputDataSetSpy.called).to.be.true;
expect(outputDataItemSpy.called).to.be.false;
});
it('should call outputDataItem function when data param is an object', async () => {
await outputData(dataItem);
expect(outputDataSetSpy.called).to.be.false;
expect(outputDataItemSpy.called).to.be.true;
});
});
describe('outputDataSet', function () {
let outputDataSet: any;
let printLineSpy: any;
this.beforeEach(() => {
const output = rewire('../../build/framework/output');
printLineSpy = sinon.spy();
output.__set__('printLine', printLineSpy);
outputDataSet = output.__get__('outputDataSet');
});
it('should only output fields specified in `fields` param, in that order', async () => {
const fields = ['id', 'name', 'thing_color'];
const options = {};
await outputDataSet(dataSet, fields, options);
// check correct number of rows (2 data, 2 header)
expect(printLineSpy.callCount).to.equal(4);
const headerLine = printLineSpy.firstCall.firstArg.toLowerCase();
// check we have fields we specified
fields.forEach((f) => {
expect(headerLine).to.include(f.replace(/_/g, ' '));
});
// check we don't have fields we didn't specify
expect(headerLine).to.not.include('thing_shape');
// check order
// split header using the `name` column as delimiter
const splitHeader = headerLine.split('name');
expect(splitHeader[0]).to.include('id');
expect(splitHeader[1]).to.include('thing');
});
/*
it('should output fields in the order specified in `fields` param', async () => {
const fields = ['thing_color', 'id', 'name'];
const options = {};
await outputDataSet(dataSet, fields, options);
const headerLine = printLineSpy.firstCall.firstArg.toLowerCase();
// split header using the `it` column as delimiter
const splitHeader = headerLine.split('id');
expect(splitHeader[0]).to.include('thing');
expect(splitHeader[1]).to.include('name');
});
*/
it('should only output fields specified in `options.fields` if present', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
// test all formats
fields: 'Name,thing_color,Thing shape',
};
await outputDataSet(dataSet, fields, options);
const headerLine = printLineSpy.firstCall.firstArg.toLowerCase();
// check we have fields we specified
expect(headerLine).to.include('name');
expect(headerLine).to.include('thing color');
expect(headerLine).to.include('thing shape');
// check we don't have fields we didn't specify
expect(headerLine).to.not.include('id');
});
it('should output records in order specified by `options.sort` if present', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
sort: 'thing shape',
'no-header': true,
};
await outputDataSet(dataSet, fields, options);
// blue should come before red
expect(printLineSpy.getCall(0).firstArg).to.include('blue');
expect(printLineSpy.getCall(1).firstArg).to.include('red');
});
it('should only output records that match filter specified by `options.filter` if present', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
filter: 'thing color=red',
'no-header': true,
};
await outputDataSet(dataSet, fields, options);
// check correct number of rows (1 matched data, no-header)
expect(printLineSpy.callCount).to.equal(1);
expect(printLineSpy.getCall(0).firstArg).to.include('red');
});
it('should output data in json format, if `options.json` true', async () => {
const fields = ['name', 'thing_color', 'thing_shape'];
const options = {
json: true,
};
// TODO: I've run into an oclif cli-ux bug, where numbers are output as strings in json
// (this can be seen by including 'id' in the fields list above).
// Issue opened: https://github.com/oclif/cli-ux/issues/309
// For now removing id for this test.
const clonedDataSet = JSON.parse(JSON.stringify(dataSet));
clonedDataSet.forEach((d: any) => {
delete d.id;
});
const expectedJson = JSON.stringify(clonedDataSet, undefined, 2);
await outputDataSet(dataSet, fields, options);
expect(printLineSpy.callCount).to.equal(1);
expect(printLineSpy.getCall(0).firstArg).to.equal(expectedJson);
});
});
describe('outputDataItem', function () {
let outputDataItem: any;
let printLineSpy: any;
this.beforeEach(() => {
const output = rewire('../../build/framework/output');
printLineSpy = sinon.spy();
output.__set__('printLine', printLineSpy);
outputDataItem = output.__get__('outputDataItem');
});
it('should only output fields specified in `fields` param, in that order', async () => {
const fields = ['id', 'name', 'thing_color'];
const options = {};
await outputDataItem(dataItem, fields, options);
// check correct number of rows (3 fields)
expect(printLineSpy.callCount).to.equal(3);
// check we have fields we specified
fields.forEach((f, index) => {
const kvPair = printLineSpy.getCall(index).firstArg.split(':');
expect(kvPair[0].toLowerCase()).to.include(f.replace(/_/g, ' '));
expect(kvPair[1]).to.include((dataItem as any)[f]);
});
});
it('should only output fields specified in `options.fields` if present', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
// test all formats
fields: 'Name,thing_color,Thing shape',
};
const expectedFields = ['name', 'thing_color', 'thing_shape'];
await outputDataItem(dataItem, fields, options);
// check correct number of rows (3 fields)
expect(printLineSpy.callCount).to.equal(3);
// check we have fields we specified
expectedFields.forEach((f, index) => {
const kvPair = printLineSpy.getCall(index).firstArg.split(':');
expect(kvPair[0].toLowerCase()).to.include(f.replace(/_/g, ' '));
expect(kvPair[1]).to.include((dataItem as any)[f]);
});
});
it('should output data in json format, if `options.json` true', async () => {
const fields = ['name', 'id', 'thing_color', 'thing_shape'];
const options = {
json: true,
};
const expectedJson = JSON.stringify(dataItem, undefined, 2);
await outputDataItem(dataItem, fields, options);
expect(printLineSpy.callCount).to.equal(1);
expect(printLineSpy.getCall(0).firstArg).to.equal(expectedJson);
});
});