Reveal live query error information in Fleet UI (#224)

This PR adds an "Errors" table to the live query UI.

Summary of changes:
- Errors table includes the columns `hostname`, `osquery_version`, and `error`
- The errors table is only rendered when at least one host fails
- Hosts with an osquery version less than 4.4.0 always display the error "upgrade osquery on this host to 4.4.0+ for error details"
This commit is contained in:
noahtalerman 2021-01-25 17:08:28 -08:00 committed by GitHub
parent 87330a9753
commit 9d8e93ee1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 222 additions and 47 deletions

View file

@ -63,7 +63,7 @@
&--offline {
&:before {
background-color: $core-red;
background-color: $ui-dark-grey;
border-radius: 100%;
content: ' ';
display: inline-block;

View file

@ -1,8 +1,10 @@
.progress-bar {
display: flex;
background-color: $ui-medium-grey;
background-color: $ui-dark-grey;
height: 10px;
overflow: hidden;
border-radius: 2px;
margin-top: 4px;
&__progress {
height: 100%;

View file

@ -11,9 +11,20 @@ const baseClass = 'query-progress-details';
const QueryProgressDetails = ({ campaign, className, onRunQuery, onStopQuery, queryIsRunning, queryTimerMilliseconds, disableRun }) => {
const { hosts_count: hostsCount } = campaign;
const { Metrics: metrics = {} } = campaign;
const { errors } = campaign;
const totalHostsCount = get(campaign, ['totals', 'count'], 0);
const totalRowsCount = get(campaign, ['query_results', 'length'], 0);
const onlineHostsTotalDisplay = metrics.OnlineHosts === 1 ? '1 host' : `${metrics.OnlineHosts} hosts`;
const onlineResultsTotalDisplay = totalRowsCount === 1 ? '1 result' : `${totalRowsCount} results`;
const offlineHostsTotalDisplay = metrics.OfflineHosts === 1 ? '1 host' : `${metrics.OfflineHosts} hosts`;
const failedHostsTotalDisplay = hostsCount.failed === 1 ? '1 host' : `${hostsCount.failed} hosts`;
let totalErrorsDisplay = '0 errors';
if (errors) {
totalErrorsDisplay = errors.length === 1 ? '1 error' : `${errors.length} errors`;
}
const runQueryBtn = (
<div className={`${baseClass}__btn-wrapper`}>
<Button
@ -51,12 +62,11 @@ const QueryProgressDetails = ({ campaign, className, onRunQuery, onStopQuery, qu
return (
<div className={`${baseClass} ${className}`}>
<div className={`${baseClass}__wrapper`}>
<span>
<b>{hostsCount.total}</b>&nbsp;of&nbsp;
<b>{totalHostsCount} Hosts</b>&nbsp;Returning&nbsp;
<b>{totalRowsCount} Records&nbsp;</b>
<em>({hostsCount.failed} failed)</em>
</span>
<div className={`${baseClass}__text-wrapper`}>
<span className={`${baseClass}__text-online`}>Online - {onlineHostsTotalDisplay} returning {onlineResultsTotalDisplay}</span>
<span className={`${baseClass}__text-offline`}>Offline - {offlineHostsTotalDisplay} returning 0 results</span>
<span className={`${baseClass}__text-error`}>Failed - {failedHostsTotalDisplay} returning {totalErrorsDisplay}</span>
</div>
<ProgressBar
error={hostsCount.failed}
max={totalHostsCount}

View file

@ -14,6 +14,48 @@
}
}
&__text-wrapper {
display: flex;
flex-direction: column;
font-size: $x-small;
}
&__text-online {
&:before {
background-color: $success;
border-radius: 100%;
content: ' ';
display: inline-block;
height: 8px;
margin-right: 8px;
width: 8px;
}
}
&__text-offline {
&:before {
background-color: $ui-dark-grey;
border-radius: 100%;
content: ' ';
display: inline-block;
height: 8px;
margin-right: 8px;
width: 8px;
}
}
&__text-error {
&:before {
background-color: $alert;
border-radius: 100%;
content: ' ';
display: inline-block;
height: 8px;
margin-right: 8px;
width: 8px;
}
}
&__btn-wrapper {
float: right;

View file

@ -56,7 +56,7 @@ class QueryResultsTable extends Component {
};
}
renderTableHeaderRowData = (column, index) => {
renderTableHeaderColumn = (column, index) => {
const filterable = column === 'hostname' ? 'host_hostname' : column;
const { activeColumn, resultsFilter } = this.state;
const { onFilterAttribute, onSetActiveColumn } = this;
@ -77,33 +77,29 @@ class QueryResultsTable extends Component {
);
}
renderTableHeaderRow = () => {
const { campaign } = this.props;
const { renderTableHeaderRowData } = this;
const { query_results: queryResults } = campaign;
renderTableHeaderRow = (rows) => {
const { renderTableHeaderColumn } = this;
const queryAttrs = omit(queryResults[0], ['host_hostname']);
const queryAttrs = omit(rows[0], ['host_hostname']);
const queryResultColumns = keys(queryAttrs);
return (
<tr>
{renderTableHeaderRowData('hostname', -1)}
{renderTableHeaderColumn('hostname', -1)}
{queryResultColumns.map((column, i) => {
return renderTableHeaderRowData(column, i);
return renderTableHeaderColumn(column, i);
})}
</tr>
);
}
renderTableRows = () => {
const { campaign } = this.props;
const { query_results: queryResults } = campaign;
renderTableRows = (rows) => {
const { resultsFilter } = this.state;
const filteredQueryResults = filterArrayByHash(queryResults, resultsFilter);
const filteredRows = filterArrayByHash(rows, resultsFilter);
return filteredQueryResults.map((queryResult) => {
return filteredRows.map((row) => {
return (
<QueryResultsRow queryResult={queryResult} />
<QueryResultsRow queryResult={row} />
);
});
}
@ -116,7 +112,6 @@ class QueryResultsTable extends Component {
const { queryIsRunning, campaign } = this.props;
const { query_results: queryResults } = campaign;
const loading = queryIsRunning && (!queryResults || !queryResults.length);
if (loading) {
@ -126,10 +121,37 @@ class QueryResultsTable extends Component {
return (
<table className={`${baseClass}__table`}>
<thead>
{renderTableHeaderRow()}
{renderTableHeaderRow(queryResults)}
</thead>
<tbody>
{renderTableRows()}
{renderTableRows(queryResults)}
</tbody>
</table>
);
}
renderErrorsTable = () => {
const {
renderTableHeaderRow,
renderTableRows,
} = this;
const { queryIsRunning, campaign } = this.props;
const { errors } = campaign;
const loading = queryIsRunning && (!errors || !errors.length);
if (loading) {
return <Spinner />;
}
return (
<table className={`${baseClass}__table`}>
<thead>
{renderTableHeaderRow(errors)}
</thead>
<tbody>
{renderTableRows(errors)}
</tbody>
</table>
);
@ -148,10 +170,11 @@ class QueryResultsTable extends Component {
queryTimerMilliseconds,
} = this.props;
const { renderTable } = this;
const { renderTable, renderErrorsTable } = this;
const { hosts_count: hostsCount, query_results: queryResults } = campaign;
const { hosts_count: hostsCount, query_results: queryResults, errors } = campaign;
const hasNoResults = !queryIsRunning && (!hostsCount.successful || (!queryResults || !queryResults.length));
const hasErrors = !queryIsRunning && (hostsCount.failed || errors);
const resultsTableWrapClass = classnames(baseClass, {
[`${baseClass}--full-screen`]: isQueryFullScreen,
@ -174,7 +197,6 @@ class QueryResultsTable extends Component {
className={`${baseClass}__full-screen`}
queryTimerMilliseconds={queryTimerMilliseconds}
/>}
<Button
className={toggleFullScreenBtnClass}
onClick={onToggleQueryFullScreen}
@ -187,13 +209,22 @@ class QueryResultsTable extends Component {
onClick={onExportQueryResults}
variant="inverse"
>
Export
Export results
</Button>
</header>
<div className={`${baseClass}__table-wrapper`}>
{hasNoResults && <em className="no-results-message">No results found</em>}
<span className={`${baseClass}__table-title`}>Results</span>
<div className={`${baseClass}__results-table-wrapper`}>
{hasNoResults && <span className="no-results-message">No results found. Check the table below for errors.</span>}
{!hasNoResults && renderTable()}
</div>
{hasErrors &&
<div className={`${baseClass}__error-table-container`}>
<span className={`${baseClass}__table-title`}>Errors</span>
<div className={`${baseClass}__error-table-wrapper`}>
{renderErrorsTable()}
</div>
</div>
}
</div>
);
}

View file

@ -49,6 +49,10 @@ const campaignWithQueryResults = {
{ host_hostname: 'dfoihgsx', cwd: '/', directory: '/root' },
{ host_hostname: 'abc123', cwd: '/', directory: '/root' },
],
Metrics: {
OnlineHosts: 2,
OfflineHosts: 0,
},
hosts_count: {
failed: 0,
successful: 2,

View file

@ -2,11 +2,14 @@
display: flex;
flex-direction: column;
background-color: $white;
padding: $pad-base;
width: 100%;
min-height: calc(500px + (#{$pad-base} * 2));
box-sizing: border-box;
max-height: 75vh;
&__table-title {
font-size: $x-small;
font-weight: $bold;
}
&__button-wrap {
@include clearfix;
@ -17,6 +20,8 @@
}
&__filter-icon {
color: $ui-dark-grey;
&--is-active {
color: $core-blue;
}
@ -27,15 +32,14 @@
width: 378px;
}
&__table-wrapper {
&__results-table-wrapper {
display: flex;
flex-grow: 1;
border: solid 1px $ui-borders;
border-radius: 3px;
box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, 0.12);
overflow: scroll;
margin-top: 30px;
min-height: 400px;
margin-top: 4px;
min-height: 200px;
max-height: 400px;
width: 100%;
.kolide-spinner {
@ -46,13 +50,29 @@
flex-grow: 1;
align-self: center;
text-align: center;
font-size: $x-small;
}
}
&__error-table-container {
margin-top: 24px;
}
&__error-table-wrapper {
display: flex;
border: solid 1px $ui-borders;
border-radius: 3px;
overflow: scroll;
margin-bottom: 40px;
margin-top: 4px;
max-height: 200px;
width: 100%;
}
&__table {
border-collapse: collapse;
color: $core-dark-blue-grey;
font-size: $small;
font-size: $x-small;
width: 100%;
}
@ -60,11 +80,16 @@
background-color: $core-light-blue-grey;
color: $core-black;
text-align: left;
border-bottom: 1px solid $ui-borders;
th {
padding: $pad-small $pad-xsmall;
padding: 12px 24px;
min-width: 125px;
.form-field {
margin-bottom: 0;
}
span {
white-space: nowrap;
@ -83,12 +108,15 @@
background-color: $white;
td {
padding: $pad-xsmall;
padding: 12px 24px;
white-space: nowrap;
}
tr {
&:nth-child(even) {
background-color: $core-light-blue-grey;
border-bottom: 1px solid $ui-borders;
&:last-child {
border-bottom: 0;
}
}
}
@ -100,10 +128,23 @@
box-shadow: 0 0 12px 0 rgba(0, 0, 0, 0.1);
border: solid 1px $ui-borders;
z-index: 99;
padding: 20px;
.query-progress-details__run-btn {
display: none;
}
.query-progress-details__stop-btn {
display: none;
}
.query-results-table__results-table-wrapper {
height: auto;
}
.query-results-table__error-table-container {
display: none;
}
}
&--shrinking {

View file

@ -196,7 +196,7 @@ describe('QueryPage - component', () => {
'resets selected targets and removed the campaign when the hostname changes',
() => {
const queryResult = { org_name: 'Kolide', org_url: 'https://kolide.co' };
const campaign = { id: 1, query_results: [queryResult], hosts_count: { total: 1 } };
const campaign = { id: 1, query_results: [queryResult], hosts_count: { total: 1 }, Metrics: { OnlineHosts: 1, OfflineHosts: 0 } };
const props = {
dispatch: noop,
loadingQueries: false,
@ -232,6 +232,10 @@ describe('QueryPage - component', () => {
successful: 1,
total: 1,
},
Metrics: {
OnlineHosts: 1,
OfflineHosts: 0,
},
query_results: [queryResult],
};
const queryResultsCSV = convertToCSV([queryResult]);
@ -263,6 +267,10 @@ describe('QueryPage - component', () => {
successful: 1,
total: 1,
},
Metrics: {
OnlineHosts: 1,
OfflineHosts: 0,
},
query_results: [queryResult],
};
const Page = mount(<QueryPage dispatch={noop} query={queryStub} selectedOsqueryTable={defaultSelectedOsqueryTable} />);

View file

@ -10,14 +10,17 @@ const updateCampaignStateFromTotals = (campaign, { data }) => {
const updateCampaignStateFromResults = (campaign, { data }) => {
const queryResults = campaign.query_results || [];
const errors = campaign.errors || [];
const hosts = campaign.hosts || [];
const { host, rows } = data;
const { host, rows, error } = data;
const { hosts_count: hostsCount } = campaign;
const newHosts = [...hosts, host];
const newQueryResults = [...queryResults, ...rows];
let newHostsCount;
if (data.error) {
let newErrors;
// Host's with osquery version above 4.4.0 receive an error message
// when the live query fails.
if (error) {
const newFailed = hostsCount.failed + 1;
const newTotal = hostsCount.successful + newFailed;
@ -26,6 +29,34 @@ const updateCampaignStateFromResults = (campaign, { data }) => {
failed: newFailed,
total: newTotal,
};
newErrors = [
...errors,
{
host_hostname: host.hostname,
osquery_version: host.osquery_version,
error,
},
];
// Host's with osquery version below 4.4.0 receive an empty error message
// when the live query fails so we create our own message.
} else if (error === '') {
const newFailed = hostsCount.failed + 1;
const newTotal = hostsCount.successful + newFailed;
newHostsCount = {
successful: hostsCount.successful,
failed: newFailed,
total: newTotal,
};
newErrors = [
...errors,
{
host_hostname: host.hostname,
osquery_version: host.osquery_version,
error: 'upgrade osquery on this host to 4.4.0+ for error details',
},
];
} else {
const newSuccessful = hostsCount.successful + 1;
const newTotal = hostsCount.failed + newSuccessful;
@ -43,6 +74,7 @@ const updateCampaignStateFromResults = (campaign, { data }) => {
hosts: newHosts,
query_results: newQueryResults,
hosts_count: newHostsCount,
errors: newErrors,
},
};
};

View file

@ -16,6 +16,7 @@ $core-blue-green: #25c3ba;
$ui-borders: #dbe3e5;
$ui-light-grey: #fafafa;
$ui-medium-grey: #e3e3e3;
$ui-dark-grey: #afbec1;
$warning: #ffad00;
// Colors for over (hover) and down (active) buttons styles

View file

@ -175,6 +175,10 @@ export const campaignStub = {
successful: 2,
total: 2,
},
Metrics: {
OnlineHosts: 2,
OfflineHosts: 0,
},
id: 1,
query_id: queryStub.id,
query_results: [queryResultStub],