mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 17:08:53 +00:00
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:
parent
87330a9753
commit
9d8e93ee1c
11 changed files with 222 additions and 47 deletions
|
|
@ -63,7 +63,7 @@
|
|||
|
||||
&--offline {
|
||||
&:before {
|
||||
background-color: $core-red;
|
||||
background-color: $ui-dark-grey;
|
||||
border-radius: 100%;
|
||||
content: ' ';
|
||||
display: inline-block;
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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> of
|
||||
<b>{totalHostsCount} Hosts</b> Returning
|
||||
<b>{totalRowsCount} Records </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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in a new issue