Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/os-versions-sortable-by-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Made the Version column sortable on the dashboard "Operating systems" card and the Software > OS page once a specific platform is selected, instead of sorting only by host count. Versions sort numerically by segment (so, for example, "26.10" sorts after "26.6"), Windows feature-update codenames (e.g. "22H1") sort by year and half, and Ubuntu LTS releases (e.g. "22.04.9 LTS") sort numerically too, rather than as plain strings. Since comparing versions across different platforms isn't meaningful, sorting by version is disabled while viewing all platforms together (which still sorts by host count), and groups results by platform (most hosts first) for the one remaining case where a platform filter can still span multiple platforms (e.g. the dashboard's Linux tab). Added `version` as a valid `order_key` for `GET /api/v1/fleet/os_versions`.
25 changes: 22 additions & 3 deletions frontend/pages/DashboardPage/cards/OperatingSystems/OSTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
import generateTableHeaders from "./OSTableConfig";

const DEFAULT_SORT_DIRECTION = "desc";
const DEFAULT_SORT_HEADER = "hosts_count";
// Defaults to sorting by host count when viewing all platforms mixed
// together (where comparing versions across platforms isn't meaningful),
// and by version once a single platform is selected.
const DEFAULT_SORT_HEADER_ALL_PLATFORMS = "hosts_count";
const DEFAULT_SORT_HEADER_SINGLE_PLATFORM = "version";
const PAGE_SIZE = 8;

const baseClass = "operating-systems";
Expand Down Expand Up @@ -40,25 +44,40 @@ const OSTable = ({
selectedPlatform,
isLoading,
}: IOSTableProps) => {
const platformHostTotals = useMemo(() => {
const totals: Record<string, number> = {};
osVersions.forEach(({ platform, hosts_count }) => {
totals[platform] = (totals[platform] ?? 0) + hosts_count;
});
return totals;
}, [osVersions]);

const columnConfigs = useMemo(
// Linux is the only platform where the distro name ("Ubuntu", "Debian",
// ...) isn't obvious from the Version column alone, so it gets the extra
// Name column that other platforms don't need.
() =>
generateTableHeaders(currentTeamId, undefined, {
includeName: selectedPlatform === "linux",
platformHostTotals,
disableVersionSort: selectedPlatform === "all",
}),
[currentTeamId, selectedPlatform]
[currentTeamId, selectedPlatform, platformHostTotals]
);

const showPaginationControls = osVersions.length > PAGE_SIZE;

const defaultSortHeader =
selectedPlatform === "all"
? DEFAULT_SORT_HEADER_ALL_PLATFORMS
: DEFAULT_SORT_HEADER_SINGLE_PLATFORM;

return (
<TableContainer
columnConfigs={columnConfigs}
data={osVersions}
isLoading={isLoading}
defaultSortHeader={DEFAULT_SORT_HEADER}
defaultSortHeader={defaultSortHeader}
defaultSortDirection={DEFAULT_SORT_DIRECTION}
resultsTitle="Operating systems"
emptyComponent={() => EmptyOS(selectedPlatform)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { IOperatingSystemVersion } from "interfaces/operating_system";

import generateTableHeaders, {
compareOSTableVersions,
compareOSVersionStrings,
} from "./OSTableConfig";

const makeRow = (
platform: string,
version: string
): IOperatingSystemVersion => ({
os_version_id: 1,
name: `${platform} ${version}`,
name_only: platform,
version,
platform,
hosts_count: 0,
vulnerabilities: [],
kernels: [],
});

// react-table negates a sortType's return value whenever `desc` is true, on
// top of whatever the sortType itself returns. This simulates that so tests
// assert on what actually ends up rendered, not the raw pre-negation value.
const renderedOrder = (
rowA: IOperatingSystemVersion,
rowB: IOperatingSystemVersion,
desc: boolean,
totals?: Record<string, number>
) => {
const raw = compareOSTableVersions(rowA, rowB, desc, totals);
return desc ? -raw : raw;
};

describe("compareOSTableVersions", () => {
it("falls back to version comparison within the same platform, flipping with direction", () => {
const a = makeRow("darwin", "26.6");
const b = makeRow("darwin", "26.10");
expect(renderedOrder(a, b, false)).toEqual(-1);
expect(renderedOrder(a, b, true)).toEqual(1);
});

it("orders platform groups by host total, most hosts first, regardless of direction", () => {
// Windows has fewer hosts but a "lower" version number than darwin —
// grouping should still put windows first because it has more hosts.
const windowsRow = makeRow("windows", "10.0.9200.100");
const darwinRow = makeRow("darwin", "26.6");
const totals = { windows: 3000, darwin: 200 };

expect(renderedOrder(windowsRow, darwinRow, false, totals)).toBeLessThan(0);
expect(renderedOrder(darwinRow, windowsRow, false, totals)).toBeGreaterThan(
0
);

// Direction toggle only flips within-group version order, not group
// order — windows must still sort first when desc is true.
expect(renderedOrder(windowsRow, darwinRow, true, totals)).toBeLessThan(0);
expect(renderedOrder(darwinRow, windowsRow, true, totals)).toBeGreaterThan(
0
);
});

it("falls back to comparing platform names when host totals tie, so groups stay together instead of relying on stable sort", () => {
const archRow = makeRow("arch", "rolling");
const debianRow = makeRow("debian", "12");
// No totals provided at all (both default to 0 — a tie).
const ascending = renderedOrder(archRow, debianRow, false);
const descending = renderedOrder(archRow, debianRow, true);

// Whatever the tiebreak order is, it must be consistent and stay the
// same regardless of direction — i.e. still deterministic, not a
// coincidence of input order.
expect(ascending).not.toEqual(0);
expect(ascending).toEqual(descending);
expect(renderedOrder(debianRow, archRow, false)).toEqual(-ascending);
});
});

describe("compareOSVersionStrings", () => {
it("compares numeric segments by magnitude, not lexically", () => {
expect(compareOSVersionStrings("26.6", "26.10")).toEqual(-1);
expect(compareOSVersionStrings("26.10", "26.6")).toEqual(1);
expect(compareOSVersionStrings("10.0.9200.100", "10.0.26200.8875")).toEqual(
-1
);
});

it("compares Windows feature-update codenames by year and half", () => {
// The reason compareOSVersionStrings exists as its own function instead
// of reusing the shared frontend/utilities/helpers.tsx compareVersions:
// that helper doesn't understand this codename shape at all.
expect(compareOSVersionStrings("21H2", "22H1")).toEqual(-1);
expect(compareOSVersionStrings("22H1", "21H2")).toEqual(1);
expect(compareOSVersionStrings("22H1", "22H2")).toEqual(-1);
expect(compareOSVersionStrings("21H2", "21H2")).toEqual(0);
});

it("treats a non-comparable version (e.g. Arch Linux's 'rolling') as older than any comparable version", () => {
expect(compareOSVersionStrings("rolling", "26.6")).toEqual(-1);
expect(compareOSVersionStrings("26.6", "rolling")).toEqual(1);
expect(compareOSVersionStrings("rolling", "rolling")).toEqual(0);
});

it("strips Ubuntu's ' LTS' suffix before comparing numerically", () => {
// osquery's os_version table reports Ubuntu LTS releases with a literal
// " LTS" suffix (e.g. "22.04.9 LTS"), which Fleet stores verbatim.
// Without stripping it, these tie as non-comparable instead of
// comparing numerically.
expect(compareOSVersionStrings("22.04.9 LTS", "22.04.15 LTS")).toEqual(-1);
expect(compareOSVersionStrings("22.04.15 LTS", "22.04.9 LTS")).toEqual(1);
expect(compareOSVersionStrings("22.04.9 lts", "22.04.15 LTS")).toEqual(-1);
});

it("treats strings that only coerce to a number via JS's loose Number() as non-comparable, not as valid segments", () => {
// Number("") === 0, Number("1.") === 1, Number(".1") === 0.1, etc. — a
// naive `.split(".").map(Number)` would silently accept these as valid
// version segments, unlike the strict server-side regex this mirrors.
expect(compareOSVersionStrings("", "26.6")).toEqual(-1);
expect(compareOSVersionStrings("26.", "26.6")).toEqual(-1);
expect(compareOSVersionStrings(".26", "26.6")).toEqual(-1);
expect(compareOSVersionStrings("+1.2", "26.6")).toEqual(-1);
expect(compareOSVersionStrings("1e2", "26.6")).toEqual(-1);
});
});

describe("generateTableHeaders", () => {
const findVersionColumn = (disableVersionSort?: boolean) =>
generateTableHeaders(undefined, undefined, { disableVersionSort }).find(
(column) => column.accessor === "version"
);

it("disables the Version column's sort when disableVersionSort is set, so 'All platforms' can't trigger a cross-platform version sort", () => {
expect(findVersionColumn(true)?.disableSortBy).toEqual(true);
});

it("leaves the Version column sortable once a specific platform is selected", () => {
expect(findVersionColumn(false)?.disableSortBy).toEqual(false);
expect(findVersionColumn(undefined)?.disableSortBy).toEqual(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import React from "react";
import { InjectedRouter } from "react-router";
import { CellProps, Column, HeaderProps } from "react-table";
import { CellProps, Column } from "react-table";

import CustomLink from "components/CustomLink";
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
Expand All @@ -15,6 +15,7 @@ import TooltipWrapper from "components/TooltipWrapper";
import TooltipWrapperArchLinuxRolling from "components/TooltipWrapperArchLinuxRolling";
import ViewAllHostsLink from "components/ViewAllHostsLink";
import {
IHeaderProps,
INumberCellProps,
IStringCellProps,
} from "interfaces/datatable_config";
Expand Down Expand Up @@ -43,14 +44,122 @@ type IVulnCellProps = CellProps<
type IHostCountCellProps = INumberCellProps<IOperatingSystemVersion>;
type IViewAllHostsLinkProps = CellProps<IOperatingSystemVersion>;

type IHostHeaderProps = HeaderProps<IOperatingSystemVersion>;
type IHostHeaderProps = IHeaderProps<IOperatingSystemVersion>;

// Windows feature-update codename, e.g. "21H2", "23H1" — one of the two
// documented shapes of fleet.OSVersion.Version (the other being
// dot-separated numbers). Treated as a [year, half] version for comparison.
const WINDOWS_FEATURE_UPDATE_PATTERN = /^(\d{2})H([12])$/;
// Matches server/service/hosts.go's numericVersionPattern. Deliberately
// stricter than `Number()` coercion, which also accepts "", "1.", ".1",
// "+1", "1e2", and hex/whitespace as valid numbers.
const NUMERIC_VERSION_PATTERN = /^\d+(\.\d+)*$/;
// Ubuntu LTS releases report a literal " LTS" suffix in fleet.OSVersion.Version
// (e.g. "22.04.9 LTS") — osquery's os_version.version column reports it that
// way and Fleet stores it verbatim. Matches server/service/hosts.go's
// ubuntuLTSSuffixPattern.
const UBUNTU_LTS_SUFFIX_PATTERN = /\s+LTS$/i;

/** Splits an OS version string into numeric segments for comparison, or
* returns null if it doesn't match a documented shape. Deliberately NOT the
* shared frontend/utilities/helpers.tsx `compareVersions` — that helper is
* intentionally kept simple for software versions (which can have messy
* suffixes but never Windows codenames), while OS versions need codename
* support and are usually one of a few clean shapes (non-comparable formats
* like Arch Linux's "rolling" fall through to null here). Mirrors
* server/service/hosts.go's versionSegments/compareOSVersions, which faces
* the same shapes server-side for the Software > OS page's sort. */
const osVersionSegments = (version: string): number[] | null => {
const windowsMatch = version.match(WINDOWS_FEATURE_UPDATE_PATTERN);
if (windowsMatch) {
return [Number(windowsMatch[1]), Number(windowsMatch[2])];
}
const withoutLTSSuffix = version.replace(UBUNTU_LTS_SUFFIX_PATTERN, "");
if (!NUMERIC_VERSION_PATTERN.test(withoutLTSSuffix)) {
return null;
}
return withoutLTSSuffix.split(".").map(Number);
};

/** Compares two OS version strings numerically by segment (so "26.10" >
* "26.6") or, for Windows feature-update codenames, by year and half (so
* "22H1" > "21H2") — rather than as plain strings. A version matching
* neither shape (e.g. Arch Linux's "rolling") isn't comparable this way, so
* it sorts before any version that is. */
export const compareOSVersionStrings = (
version1: string,
version2: string
): number => {
const v1Parts = osVersionSegments(version1);
const v2Parts = osVersionSegments(version2);

if (!v1Parts && !v2Parts) return 0;
if (!v1Parts) return -1;
if (!v2Parts) return 1;
Comment thread
kevinmcox marked this conversation as resolved.

const maxLength = Math.max(v1Parts.length, v2Parts.length);
for (let i = 0; i < maxLength; i += 1) {
const v1Part = v1Parts[i] || 0;
const v2Part = v2Parts[i] || 0;
if (v1Part !== v2Part) return v1Part < v2Part ? -1 : 1;
}
return 0;
};

interface IOSTableConfigOptions {
includeName?: boolean;
includeVulnerabilities?: boolean;
includeIcon?: boolean;
/** Sum of hosts_count per platform *within the rows currently loaded*
* (the dashboard card fetches without page/per_page, so the API defaults
* to the first 20 results — not necessarily every platform's fleet-wide
* total, if there are more than 20 distinct OS versions) — used to group
* the Version column's sort by platform (most hosts first) before
* ordering by version within each platform, for the one remaining case
* where a single platform filter can still span multiple actual
* `platform` values (e.g. the dashboard's Linux tab, which covers rhel/
* ubuntu/etc). Only used for client-side sorting (the dashboard card);
* the server-driven Software > OS table ignores this column's sortType
* entirely. */
platformHostTotals?: Record<string, number>;
/** Disables the Version column's sort entirely — set when viewing "All
* platforms" (mixed), since comparing versions across platforms isn't
* meaningful (e.g. macOS "26.6" vs. Windows "22H1") and grouping by
* platform to work around that isn't wanted here either; sorting by
* version should only be offered once a specific platform is selected. */
disableVersionSort?: boolean;
}

/** Orders the Version column by platform group (most hosts first, always
* — this part doesn't flip with `desc`, since counteracting react-table's
* blanket sign flip when sorted descending keeps the group order fixed),
* then by version within each platform group (which does flip with
* `desc`, same as any other ascending comparator) — comparing versions
* across platforms isn't meaningful (e.g. macOS "26.6" vs. Windows
* "22H1"). If two platforms tie on host total (including both being
* absent from `platformHostTotals`), falls back to comparing platform
* names so rows still group together instead of relying on the sort
* being stable. */
export const compareOSTableVersions = (
rowA: IOperatingSystemVersion,
rowB: IOperatingSystemVersion,
desc: boolean,
platformHostTotals: Record<string, number> = {}
): number => {
if (rowA.platform !== rowB.platform) {
const totalA = platformHostTotals[rowA.platform] ?? 0;
const totalB = platformHostTotals[rowB.platform] ?? 0;
// Fixed (direction-invariant) target: most hosts first, or
// alphabetical by platform name if host totals tie.
const target =
totalA === totalB
? rowA.platform.localeCompare(rowB.platform)
: totalB - totalA;
return desc ? -target : target;
}
return compareOSVersionStrings(rowA.version, rowB.version);
};

const generateDefaultTableHeaders = (
teamId?: number,
router?: InjectedRouter,
Expand Down Expand Up @@ -96,8 +205,22 @@ const generateDefaultTableHeaders = (
},
},
{
Header: "Version",
disableSortBy: true,
Header: (cellProps: IHostHeaderProps) => (
<HeaderCell
value="Version"
disableSortBy={!!configOptions?.disableVersionSort}
isSortedDesc={cellProps.column.isSortedDesc}
/>
),
disableSortBy: !!configOptions?.disableVersionSort,
accessor: "version",
sortType: (rowA, rowB, _columnId, desc) =>
compareOSTableVersions(
rowA.original,
rowB.original,
!!desc,
configOptions?.platformHostTotals
),
Cell: (cellProps: IVersionCellProps) => {
const { version, name_only } = cellProps.row.original;
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,11 @@ const OperatingSystems = ({
<DataError verticalPaddingSize="pad-large" />
) : (
<OSTable
// Remounts on platform change so react-table re-applies its
// default sort (version — this card only ever renders for a
// single platform, never "all") instead of carrying over a
// sort the user manually clicked on the previous platform.
key={selectedPlatform}
currentTeamId={currentTeamId}
osVersions={osVersions}
selectedPlatform={selectedPlatform}
Expand Down
Loading
Loading