fix: sort order for Last restarted (#14878)

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/` or
`orbit/changes/`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [x] Documented any API changes (docs/REST API/rest-api.md or
docs/Contributing/API-for-contributors.md)
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Rachael Shaw <r@rachael.wtf>
This commit is contained in:
Jahziel Villasana-Espinoza 2023-11-15 16:42:57 -05:00 committed by GitHub
parent e9a84dbda0
commit 1fca8b1e38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 112 additions and 51 deletions

2
changes/13160-sort-order Normal file
View File

@ -0,0 +1,2 @@
- Fixed an edge case sorting bug by consolidating the logic for generating the `last_restarted`
value for hosts into the backend.

View File

@ -9,6 +9,7 @@
"label_updated_at": "0001-01-01T00:00:00Z",
"policy_updated_at": "0001-01-01T00:00:00Z",
"last_enrolled_at": "0001-01-01T00:00:00Z",
"last_restarted_at": "0001-01-01T00:00:00Z",
"seen_time": "0001-01-01T00:00:00Z",
"software_updated_at": "0001-01-01T00:00:00Z",
"refetch_requested": false,
@ -93,4 +94,4 @@
"display_text": "test_host",
"display_name": "test_host"
}
}
}

View File

@ -28,6 +28,7 @@ spec:
label_updated_at: "0001-01-01T00:00:00Z"
labels: []
last_enrolled_at: "0001-01-01T00:00:00Z"
last_restarted_at: "0001-01-01T00:00:00Z"
logger_tls_period: 0
mdm:
encryption_key_available: false

View File

@ -8,6 +8,7 @@
"detail_updated_at": "0001-01-01T00:00:00Z",
"label_updated_at": "0001-01-01T00:00:00Z",
"last_enrolled_at": "0001-01-01T00:00:00Z",
"last_restarted_at": "0001-01-01T00:00:00Z",
"seen_time": "0001-01-01T00:00:00Z",
"software_updated_at": "0001-01-01T00:00:00Z",
"refetch_requested": false,
@ -77,6 +78,7 @@
"detail_updated_at": "0001-01-01T00:00:00Z",
"label_updated_at": "0001-01-01T00:00:00Z",
"last_enrolled_at": "0001-01-01T00:00:00Z",
"last_restarted_at": "0001-01-01T00:00:00Z",
"seen_time": "0001-01-01T00:00:00Z",
"software_updated_at": "0001-01-01T00:00:00Z",
"refetch_requested": false,
@ -127,4 +129,4 @@
"status": "offline",
"display_text": "test_host2"
}
}
}

View File

@ -9,6 +9,7 @@
"detail_updated_at": "0001-01-01T00:00:00Z",
"label_updated_at": "0001-01-01T00:00:00Z",
"last_enrolled_at": "0001-01-01T00:00:00Z",
"last_restarted_at": "0001-01-01T00:00:00Z",
"seen_time": "0001-01-01T00:00:00Z",
"software_updated_at": "0001-01-01T00:00:00Z",
"refetch_requested": false,
@ -78,6 +79,7 @@
"detail_updated_at": "0001-01-01T00:00:00Z",
"label_updated_at": "0001-01-01T00:00:00Z",
"last_enrolled_at": "0001-01-01T00:00:00Z",
"last_restarted_at": "0001-01-01T00:00:00Z",
"seen_time": "0001-01-01T00:00:00Z",
"software_updated_at": "0001-01-01T00:00:00Z",
"refetch_requested": false,
@ -129,4 +131,4 @@
"display_text": "test_host2"
}
}
]
]

View File

@ -32,6 +32,7 @@ spec:
total_issues_count: 0
label_updated_at: "0001-01-01T00:00:00Z"
last_enrolled_at: "0001-01-01T00:00:00Z"
last_restarted_at: "0001-01-01T00:00:00Z"
logger_tls_period: 0
mdm:
encryption_key_available: false
@ -88,6 +89,7 @@ spec:
total_issues_count: 0
label_updated_at: "0001-01-01T00:00:00Z"
last_enrolled_at: "0001-01-01T00:00:00Z"
last_restarted_at: "0001-01-01T00:00:00Z"
logger_tls_period: 0
mdm:
encryption_key_available: false

View File

@ -1836,6 +1836,7 @@ the `software` table.
- `policy_updated_at`: the last time we updated the policy results for the host based on the queries ran.
- `seen_time`: the last time the host contacted the fleet server, regardless of what operation it was for.
- `software_updated_at`: the last time software changed for the host in any way.
- `last_restarted_at`: the last time that the host was restarted.
### List hosts
@ -1912,6 +1913,7 @@ If `after` is being used with `created_at` or `updated_at`, the table must be sp
"updated_at": "2020-11-05T06:03:39Z",
"id": 1,
"detail_updated_at": "2020-11-05T05:09:45Z",
"last_restarted_at": "2020-11-01T03:01:45Z",
"software_updated_at": "2020-11-05T05:09:44Z",
"label_updated_at": "2020-11-05T05:14:51Z",
"policy_updated_at": "2023-06-26T18:33:15Z",
@ -2221,6 +2223,7 @@ Returns the information of the specified host.
],
"id": 1,
"detail_updated_at": "2021-08-19T21:07:53Z",
"last_restarted_at": "2020-11-01T03:01:45Z",
"software_updated_at": "2020-11-05T05:09:44Z",
"label_updated_at": "2021-08-19T21:07:53Z",
"policy_updated_at": "2023-06-26T18:33:15Z",

View File

@ -20,6 +20,7 @@ const DEFAULT_HOST_MOCK: IHost = {
created_at: "2022-01-01T12:00:00Z",
updated_at: "2022-01-02T12:00:00Z",
detail_updated_at: "2022-01-02T12:00:00Z",
last_restarted_at: "2022-01-02T12:00:00Z",
label_updated_at: "2022-01-02T12:00:00Z",
policy_updated_at: "2022-01-02T12:00:00Z",
last_enrolled_at: "2022-01-02T12:00:00Z",

View File

@ -19,6 +19,7 @@ export default PropTypes.shape({
updated_at: PropTypes.string,
id: PropTypes.number,
detail_updated_at: PropTypes.string,
last_restarted_at: PropTypes.string,
label_updated_at: PropTypes.string,
policy_updated_at: PropTypes.string,
last_enrolled_at: PropTypes.string,
@ -199,6 +200,7 @@ export interface IHost {
updated_at: string;
id: number;
detail_updated_at: string;
last_restarted_at: string;
label_updated_at: string;
policy_updated_at: string;
last_enrolled_at: string;

View File

@ -21,7 +21,6 @@ import NotSupported from "components/NotSupported";
import {
humanHostMemory,
humanHostLastRestart,
humanHostLastSeen,
hostTeamName,
} from "utilities/helpers";
@ -568,9 +567,9 @@ const allHostTableHeaders: IDataColumn[] = [
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
accessor: "uptime",
accessor: "last_restarted_at",
Cell: (cellProps: ICellProps) => {
const { uptime, detail_updated_at, platform } = cellProps.row.original;
const { platform, last_restarted_at } = cellProps.row.original;
if (platform === "chrome") {
return NotSupported;
@ -578,7 +577,7 @@ const allHostTableHeaders: IDataColumn[] = [
return (
<TextCell
value={{
timeString: humanHostLastRestart(detail_updated_at, uptime),
timeString: last_restarted_at,
}}
formatter={HumanTimeDiffWithFleetLaunchCutoff}
/>

View File

@ -729,10 +729,18 @@ const ManageHostsPage = ({
let sort = sortBy;
if (sortHeader) {
let direction = sortDirection;
if (sortHeader === "last_restarted_at") {
if (sortDirection === "asc") {
direction = "desc";
} else {
direction = "asc";
}
}
sort = [
{
key: sortHeader,
direction: sortDirection || DEFAULT_SORT_DIRECTION,
direction: direction || DEFAULT_SORT_DIRECTION,
},
];
} else if (!sortBy.length) {

View File

@ -399,6 +399,7 @@ const HostDetailsPage = ({
"geolocation",
"batteries",
"detail_updated_at",
"last_restarted_at",
])
);

View File

@ -6,7 +6,6 @@ import TooltipWrapper from "components/TooltipWrapper";
import CustomLink from "components/CustomLink";
import { IHostMdmData, IMunkiData, IDeviceUser } from "interfaces/host";
import { humanHostLastRestart } from "utilities/helpers";
import {
DEFAULT_EMPTY_CELL_VALUE,
MDM_STATUS_TOOLTIP,
@ -209,10 +208,7 @@ const About = ({
<span className="info-grid__header">Last restarted</span>
<span className="info-grid__data">
<HumanTimeDiffWithFleetLaunchCutoff
timeString={humanHostLastRestart(
aboutData.detail_updated_at,
aboutData.uptime
)}
timeString={aboutData.last_restarted_at}
/>
</span>
</div>

View File

@ -547,40 +547,6 @@ export const inMilliseconds = (nanoseconds: number): number => {
return nanoseconds / NANOSECONDS_PER_MILLISECOND;
};
export const humanHostLastRestart = (
detailUpdatedAt: string,
uptime: number | string
): string => {
if (
!detailUpdatedAt ||
!uptime ||
detailUpdatedAt === DEFAULT_EMPTY_CELL_VALUE ||
detailUpdatedAt < INITIAL_FLEET_DATE ||
typeof uptime !== "number"
) {
return "Unavailable";
}
try {
const currentDate = new Date();
const updatedDate = new Date(detailUpdatedAt);
const millisecondsLastUpdated =
currentDate.getTime() - updatedDate.getTime();
// Sum of calculated milliseconds since last updated with uptime
const millisecondsLastRestart =
millisecondsLastUpdated + uptime / NANOSECONDS_PER_MILLISECOND;
const restartDate = new Date();
restartDate.setMilliseconds(
restartDate.getMilliseconds() - millisecondsLastRestart
);
return restartDate.toISOString();
} catch {
return "Unavailable";
}
};
export const humanHostLastSeen = (lastSeen: string): string => {
if (!lastSeen || lastSeen < INITIAL_FLEET_DATE) {
return "Never";

View File

@ -579,6 +579,7 @@ SELECT
COALESCE(hst.seen_time, h.created_at) AS seen_time,
t.name AS team_name,
COALESCE(hu.software_updated_at, h.created_at) AS software_updated_at,
(CASE WHEN uptime = 0 THEN DATE('0001-01-01') ELSE DATE_SUB(h.detail_updated_at, INTERVAL uptime/1000 MICROSECOND) END) as last_restarted_at,
(
SELECT
additional
@ -848,7 +849,8 @@ func (ds *Datastore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt
COALESCE(hd.percent_disk_space_available, 0) as percent_disk_space_available,
COALESCE(hst.seen_time, h.created_at) AS seen_time,
t.name AS team_name,
COALESCE(hu.software_updated_at, h.created_at) AS software_updated_at
COALESCE(hu.software_updated_at, h.created_at) AS software_updated_at,
(CASE WHEN uptime = 0 THEN DATE('0001-01-01') ELSE DATE_SUB(h.detail_updated_at, INTERVAL uptime/1000 MICROSECOND) END) as last_restarted_at
`
sql += hostMDMSelect

View File

@ -154,6 +154,7 @@ func TestHosts(t *testing.T) {
{"GetMatchingHostSerials", testGetMatchingHostSerials},
{"ListHostsLiteByIDs", testHostsListHostsLiteByIDs},
{"ListHostsWithPagination", testListHostsWithPagination},
{"LastRestarted", testLastRestarted},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -7530,3 +7531,72 @@ func testListHostsWithPagination(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.Equal(t, hostCount, count)
}
func testLastRestarted(t *testing.T, ds *Datastore) {
ctx := context.Background()
// Arbitrary value
const uptimeVal = 16691000000000
now := time.Now()
newHostFunc := func(name string, uptimeZero bool) (*fleet.Host, time.Time) {
newHost := &fleet.Host{
DetailUpdatedAt: now,
LabelUpdatedAt: now,
PolicyUpdatedAt: now,
SeenTime: now,
NodeKey: ptr.String(name),
UUID: name,
Hostname: "foo.local." + name,
}
var expectedLastRestartedAt time.Time
if uptimeZero {
newHost.Uptime = 0
} else {
newHost.Uptime = uptimeVal
// Rounding to nearest second because the SQL query does integer division.
expectedLastRestartedAt = newHost.DetailUpdatedAt.Add(-newHost.Uptime).Round(time.Second).UTC()
}
host, err := ds.NewHost(ctx, newHost)
require.NoError(t, err)
require.NotNil(t, host)
return host, expectedLastRestartedAt
}
hostCount := 10
hosts := make([]*fleet.Host, 0, hostCount)
hostsToVals := make(map[uint]time.Time, 0)
for i := 0; i < hostCount; i++ {
nh, expectedVal := newHostFunc(fmt.Sprintf("h%d", i), i%2 == 0)
hosts = append(hosts, nh)
hostsToVals[nh.ID] = expectedVal
}
opts := fleet.HostListOptions{}
userFilter := fleet.TeamFilter{User: test.UserAdmin}
returnedHosts := listHostsCheckCount(t, ds, userFilter, opts, len(hosts))
for i, h := range returnedHosts {
require.Equal(t, hosts[i].Uptime, h.Uptime)
require.Equal(t, hostsToVals[h.ID], h.LastRestartedAt)
}
h1 := hosts[0] // has Uptime == 0
h2 := hosts[1] // has Uptime == uptimeVal
host, err := ds.Host(ctx, h1.ID)
require.NoError(t, err)
require.Equal(t, h1.ID, host.ID)
require.Equal(t, time.Duration(0), host.Uptime)
require.Equal(t, hostsToVals[host.ID], host.LastRestartedAt)
host, err = ds.Host(ctx, h2.ID)
require.NoError(t, err)
require.Equal(t, h2.ID, host.ID)
require.Equal(t, time.Duration(uptimeVal), host.Uptime)
require.Equal(t, hostsToVals[host.ID], host.LastRestartedAt)
}

View File

@ -316,6 +316,9 @@ type Host struct {
// The boolean is based on information ingested from the Apple DEP API that is stored in the
// host_dep_assignments table.
DEPAssignedToFleet *bool `json:"dep_assigned_to_fleet,omitempty" db:"dep_assigned_to_fleet" csv:"-"`
// LastRestartedAt is a UNIX timestamp that indicates when the Host was last restarted.
LastRestartedAt time.Time `json:"last_restarted_at" db:"last_restarted_at"`
}
type MDMHostData struct {

View File

@ -6326,7 +6326,7 @@ func (s *integrationTestSuite) TestHostsReportDownload() {
res.Body.Close()
require.NoError(t, err)
require.Len(t, rows, len(hosts)+1) // all hosts + header row
require.Len(t, rows[0], 48) // total number of cols
require.Len(t, rows[0], 49) // total number of cols
const (
idCol = 3
@ -8029,7 +8029,7 @@ func (s *integrationTestSuite) TestHostsReportWithPolicyResults() {
res.Body.Close()
require.NoError(t, err)
require.Len(t, rows1, len(hosts)+1) // all hosts + header row
require.Len(t, rows1[0], 48) // total number of cols
require.Len(t, rows1[0], 49) // total number of cols
var (
idIdx int
@ -8056,7 +8056,7 @@ func (s *integrationTestSuite) TestHostsReportWithPolicyResults() {
res.Body.Close()
require.NoError(t, err)
require.Len(t, rows2, len(hosts)+1) // all hosts + header row
require.Len(t, rows2[0], 48) // total number of cols
require.Len(t, rows2[0], 49) // total number of cols
// Check that all hosts have 0 issues and that they match the previous call to `/hosts/report`.
for i := 1; i < len(hosts)+1; i++ {