Add Apple marketing names to backend, frontend, and an osquery table (#46482)
**Related issue:** Resolves https://github.com/fleetdm/fleet/issues/46818 and https://github.com/fleetdm/fleet/issues/48524. # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## fleetd/orbit/Fleet Desktop - [x] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [x] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [x] Verified that fleetd runs on macOS, Linux and Windows - [x] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Host lists and Host details now show human‑readable Apple hardware marketing names (macOS, iOS, iPadOS) where available (e.g., "MacBook Pro (16‑inch, 2021)"), replacing raw model identifiers. * Hardware model displays fall back to the original model identifier for non‑Apple or unmapped devices. * **Bug Fixes / CSV** * Exported host CSVs now align with the UI by using the marketing name for Apple devices when available. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
Lucas Manuel Rodriguez
parent
5db4146b4f
commit
bf3e1bab99
@@ -0,0 +1 @@
|
||||
- Added marketing name display for Apple devices (macOS, iOS, iPadOS) on the Hosts and Host details pages. The "Hardware model" field now shows human-readable names (e.g. "MacBook Pro (16-inch, 2021)") instead of raw identifiers (e.g. "MacBookPro18,1").
|
||||
@@ -23,6 +23,7 @@
|
||||
"gigs_all_disk_space": null,
|
||||
"gigs_disk_space_available": 0,
|
||||
"gigs_total_disk_space": 0,
|
||||
"hardware_marketing_name": "",
|
||||
"hardware_model": "",
|
||||
"hardware_serial": "",
|
||||
"hardware_vendor": "",
|
||||
|
||||
@@ -23,6 +23,7 @@ spec:
|
||||
gigs_all_disk_space: null
|
||||
gigs_disk_space_available: 0
|
||||
gigs_total_disk_space: 0
|
||||
hardware_marketing_name: ""
|
||||
hardware_model: ""
|
||||
hardware_serial: ""
|
||||
hardware_vendor: ""
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"gigs_all_disk_space": null,
|
||||
"gigs_disk_space_available": 0,
|
||||
"gigs_total_disk_space": 0,
|
||||
"hardware_marketing_name": "",
|
||||
"hardware_model": "",
|
||||
"hardware_serial": "",
|
||||
"hardware_vendor": "",
|
||||
@@ -103,6 +104,7 @@
|
||||
"gigs_all_disk_space": null,
|
||||
"gigs_disk_space_available": 0,
|
||||
"gigs_total_disk_space": 0,
|
||||
"hardware_marketing_name": "",
|
||||
"hardware_model": "",
|
||||
"hardware_serial": "",
|
||||
"hardware_vendor": "",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"gigs_all_disk_space": null,
|
||||
"gigs_disk_space_available": 0,
|
||||
"gigs_total_disk_space": 0,
|
||||
"hardware_marketing_name": "",
|
||||
"hardware_model": "",
|
||||
"hardware_serial": "",
|
||||
"hardware_vendor": "",
|
||||
@@ -104,6 +105,7 @@
|
||||
"gigs_all_disk_space": null,
|
||||
"gigs_disk_space_available": 0,
|
||||
"gigs_total_disk_space": 0,
|
||||
"hardware_marketing_name": "",
|
||||
"hardware_model": "",
|
||||
"hardware_serial": "",
|
||||
"hardware_vendor": "",
|
||||
|
||||
@@ -26,6 +26,7 @@ spec:
|
||||
gigs_all_disk_space: null
|
||||
gigs_disk_space_available: 0
|
||||
gigs_total_disk_space: 0
|
||||
hardware_marketing_name: ""
|
||||
hardware_model: ""
|
||||
hardware_serial: ""
|
||||
hardware_vendor: ""
|
||||
@@ -95,6 +96,7 @@ spec:
|
||||
gigs_all_disk_space: null
|
||||
gigs_disk_space_available: 0
|
||||
gigs_total_disk_space: 0
|
||||
hardware_marketing_name: ""
|
||||
hardware_model: ""
|
||||
hardware_serial: ""
|
||||
hardware_vendor: ""
|
||||
|
||||
@@ -64,6 +64,7 @@ const DEFAULT_HOST_MOCK: IHost = {
|
||||
cpu_logical_cores: 8,
|
||||
hardware_vendor: "",
|
||||
hardware_model: "",
|
||||
hardware_marketing_name: "",
|
||||
hardware_version: "",
|
||||
hardware_serial: "",
|
||||
computer_name: "9b20fc72a247",
|
||||
|
||||
+8
-1
@@ -19,6 +19,10 @@ interface ITooltipTruncatedTextCellProps {
|
||||
prefix?: React.ReactNode;
|
||||
/** Content does not get truncated */
|
||||
suffix?: React.ReactNode;
|
||||
/** When `true`, show the tooltip even when the text is not truncated. Use
|
||||
* when the tooltip carries supplemental info (e.g. a raw identifier behind a
|
||||
* friendlier display value) rather than just the truncated text. */
|
||||
alwaysShowTooltip?: boolean;
|
||||
}
|
||||
|
||||
const baseClass = "tooltip-truncated-cell";
|
||||
@@ -30,6 +34,7 @@ const TooltipTruncatedTextCell = ({
|
||||
className,
|
||||
prefix,
|
||||
suffix,
|
||||
alwaysShowTooltip = false,
|
||||
}: ITooltipTruncatedTextCellProps): JSX.Element => {
|
||||
const classNames = classnames(baseClass, className, {
|
||||
"tooltip-break-on-word": tooltipBreakOnWord,
|
||||
@@ -62,7 +67,9 @@ const TooltipTruncatedTextCell = ({
|
||||
className="data-table__tooltip-truncated-text-container"
|
||||
data-tip
|
||||
data-for={tooltipId}
|
||||
data-tip-disable={isDefaultValue || tooltipDisabled}
|
||||
data-tip-disable={
|
||||
isDefaultValue || (tooltipDisabled && !alwaysShowTooltip)
|
||||
}
|
||||
>
|
||||
<span
|
||||
ref={ref}
|
||||
|
||||
@@ -19,6 +19,10 @@ interface ITooltipTruncatedTextCellProps {
|
||||
/** When `true`, suppress the tooltip even if the text is truncated. Useful
|
||||
* when a parent surface owns the hover tooltip. */
|
||||
disableTooltip?: boolean;
|
||||
/** When `true`, show the tooltip even when the text is not truncated. Use
|
||||
* when the tooltip carries supplemental info (e.g. a raw identifier behind a
|
||||
* friendlier display value) rather than just the truncated text. */
|
||||
alwaysShowTooltip?: boolean;
|
||||
}
|
||||
|
||||
const baseClass = "tooltip-truncated-text";
|
||||
@@ -31,18 +35,30 @@ const TooltipTruncatedText = ({
|
||||
isMobileView = false,
|
||||
fixedPositionStrategy = false,
|
||||
disableTooltip = false,
|
||||
alwaysShowTooltip = false,
|
||||
}: ITooltipTruncatedTextCellProps): JSX.Element => {
|
||||
const classNames = classnames(baseClass, className);
|
||||
|
||||
// Tooltip visibility logic: Enable only when text is truncated
|
||||
// Tooltip visibility logic: Enable when text is truncated, or always when
|
||||
// `alwaysShowTooltip` is set (supplemental info tooltip).
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const isTruncated = useCheckTruncatedElement(ref);
|
||||
|
||||
const showTooltip = !disableTooltip && (isTruncated || alwaysShowTooltip);
|
||||
// Underline the value to signal a supplemental tooltip is available.
|
||||
// Truncation-only tooltips are not underlined.
|
||||
const underline = showTooltip && alwaysShowTooltip;
|
||||
|
||||
const classNames = classnames(baseClass, className, {
|
||||
[`${baseClass}--underline`]: underline,
|
||||
});
|
||||
|
||||
// TODO: RachelPerkins unreleased bug refactor to include mobile tapping/click
|
||||
return (
|
||||
<TooltipWrapper
|
||||
className={classNames}
|
||||
disableTooltip={disableTooltip || !isTruncated}
|
||||
disableTooltip={!showTooltip}
|
||||
// The underline is applied on the text value via `--underline` (see
|
||||
// _styles.scss) instead of TooltipWrapper's own underline, whose
|
||||
// negative margin gets clipped by truncating (`overflow: hidden`) parents.
|
||||
underline={false}
|
||||
position={tooltipPosition}
|
||||
showArrow
|
||||
|
||||
@@ -16,4 +16,14 @@
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// When a supplemental tooltip is available, underline the value to signal it.
|
||||
// The dashed border sits on the text value itself (not the wrapper element)
|
||||
// so it isn't clipped by truncating (`overflow: hidden`) parents such as the
|
||||
// DataSet `dd`.
|
||||
&--underline &__text-value {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
border-bottom: 1px dashed $ui-fleet-black-50;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +353,7 @@ export interface IHost {
|
||||
cpu_logical_cores: number;
|
||||
hardware_vendor: string;
|
||||
hardware_model: string;
|
||||
hardware_marketing_name: string;
|
||||
hardware_version: string;
|
||||
hardware_serial: string;
|
||||
computer_name: string;
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
} from "interfaces/datatable_config";
|
||||
import PATHS from "router/paths";
|
||||
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
|
||||
import { getHostStatusTooltipText } from "../helpers";
|
||||
import { getHardwareModelDisplay, getHostStatusTooltipText } from "../helpers";
|
||||
|
||||
type IHostTableColumnConfig = Column<IHost> & {
|
||||
// This is used to prevent these columns from being hidden. This will be
|
||||
@@ -189,9 +189,21 @@ const allHostTableHeaders = (teamId?: number): IHostTableColumnConfig[] => [
|
||||
),
|
||||
accessor: "hardware_model",
|
||||
id: "hardware_model",
|
||||
Cell: (cellProps: IHostTableStringCellProps) => (
|
||||
<TooltipTruncatedTextCell value={cellProps.cell.value} className="w250" />
|
||||
),
|
||||
Cell: (cellProps: IHostTableStringCellProps) => {
|
||||
const { value, tooltip, alwaysShowTooltip } = getHardwareModelDisplay(
|
||||
cellProps.row.original.platform,
|
||||
cellProps.cell.value,
|
||||
cellProps.row.original.hardware_marketing_name
|
||||
);
|
||||
return (
|
||||
<TooltipTruncatedTextCell
|
||||
value={value}
|
||||
tooltip={tooltip}
|
||||
alwaysShowTooltip={alwaysShowTooltip}
|
||||
className="w250"
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
// User email
|
||||
{
|
||||
|
||||
@@ -1638,10 +1638,14 @@ const ManageHostsPage = ({
|
||||
.filter((element) => element !== "" && element !== "selection")
|
||||
// "agent" is a display-only column that coalesces orbit and osquery
|
||||
// versions; it has no corresponding CSV field on the backend, so we
|
||||
// substitute the real fields it's derived from.
|
||||
// substitute the real fields it's derived from. Likewise, the
|
||||
// "hardware_model" column also surfaces the Apple marketing name in
|
||||
// the UI, so we export both fields separately.
|
||||
.reduce((acc: string[], element) => {
|
||||
if (element === "agent") {
|
||||
acc.push("orbit_version", "osquery_version");
|
||||
} else if (element === "hardware_model") {
|
||||
acc.push("hardware_model", "hardware_marketing_name");
|
||||
} else {
|
||||
acc.push(element);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ describe("Vitals Card component", () => {
|
||||
it("renders Enrollment ID and Hardware model for personally enrolled iOS hosts", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "ios",
|
||||
hardware_model: "iPhone 12",
|
||||
hardware_model: "iPhone12,1",
|
||||
hardware_marketing_name: "iPhone 11",
|
||||
hardware_serial: "",
|
||||
uuid: "enrollment-id-12345",
|
||||
mdm: createMockHostMdmData({
|
||||
@@ -65,7 +66,7 @@ describe("Vitals Card component", () => {
|
||||
expect(screen.getByText("Enrollment ID")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("enrollment-id-12345")[0]).toBeInTheDocument();
|
||||
expect(screen.getByText("Hardware model")).toBeInTheDocument();
|
||||
expect(screen.getByText("iPhone 12")).toBeInTheDocument();
|
||||
expect(screen.getByText("iPhone 11")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Serial number")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Private IP address")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Public IP address")).not.toBeInTheDocument();
|
||||
@@ -74,7 +75,8 @@ describe("Vitals Card component", () => {
|
||||
it("renders Enrollment ID and Hardware model for personally enrolled iPad hosts", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "ipados",
|
||||
hardware_model: "IPad Pro",
|
||||
hardware_model: "iPad14,5",
|
||||
hardware_marketing_name: "iPad Pro 12.9-inch (6th generation) Wi-Fi",
|
||||
hardware_serial: "",
|
||||
uuid: "enrollment-id-12345",
|
||||
mdm: createMockHostMdmData({
|
||||
@@ -87,7 +89,9 @@ describe("Vitals Card component", () => {
|
||||
expect(screen.getByText("Enrollment ID")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("enrollment-id-12345")[0]).toBeInTheDocument();
|
||||
expect(screen.getByText("Hardware model")).toBeInTheDocument();
|
||||
expect(screen.getByText("IPad Pro")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("iPad Pro 12.9-inch (6th generation) Wi-Fi")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("Serial number")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Private IP address")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Public IP address")).not.toBeInTheDocument();
|
||||
@@ -96,7 +100,8 @@ describe("Vitals Card component", () => {
|
||||
it("renders Serial number and Hardware model for non-personally enrolled iOS hosts", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "ios",
|
||||
hardware_model: "iPhone 12",
|
||||
hardware_model: "iPhone12,1",
|
||||
hardware_marketing_name: "iPhone 11",
|
||||
hardware_serial: "123-456-789",
|
||||
uuid: "enrollment-id-12345",
|
||||
mdm: createMockHostMdmData({
|
||||
@@ -107,7 +112,7 @@ describe("Vitals Card component", () => {
|
||||
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
|
||||
|
||||
expect(screen.getByText("Hardware model")).toBeInTheDocument();
|
||||
expect(screen.getByText("iPhone 12")).toBeInTheDocument();
|
||||
expect(screen.getByText("iPhone 11")).toBeInTheDocument();
|
||||
expect(screen.getByText("Serial number")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("123-456-789")[0]).toBeInTheDocument();
|
||||
expect(screen.queryByText("Enrollment ID")).not.toBeInTheDocument();
|
||||
@@ -118,7 +123,8 @@ describe("Vitals Card component", () => {
|
||||
it("renders Enrollment ID and Hardware model for non-personally enrolled iPad hosts", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "ipados",
|
||||
hardware_model: "IPad Pro",
|
||||
hardware_model: "iPad14,5",
|
||||
hardware_marketing_name: "iPad Pro 12.9-inch (6th generation) Wi-Fi",
|
||||
hardware_serial: "123-456-789",
|
||||
uuid: "enrollment-id-12345",
|
||||
mdm: createMockHostMdmData({
|
||||
@@ -129,7 +135,9 @@ describe("Vitals Card component", () => {
|
||||
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
|
||||
|
||||
expect(screen.getByText("Hardware model")).toBeInTheDocument();
|
||||
expect(screen.getByText("IPad Pro")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("iPad Pro 12.9-inch (6th generation) Wi-Fi")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Serial number")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("123-456-789")[0]).toBeInTheDocument();
|
||||
expect(screen.queryByText("Enrollment ID")).not.toBeInTheDocument();
|
||||
@@ -140,7 +148,8 @@ describe("Vitals Card component", () => {
|
||||
it("render Hardware model, IP addresses, and EnrollmentID for all non android and ios/ipad hosts that have enrolled their personal mdm devices", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "darwin",
|
||||
hardware_model: "MacBook Pro",
|
||||
hardware_model: "MacBookPro18,1",
|
||||
hardware_marketing_name: "MacBook Pro (16-inch, 2021)",
|
||||
hardware_serial: "",
|
||||
primary_ip: "192.168.1.1",
|
||||
public_ip: "203.0.113.1",
|
||||
@@ -155,7 +164,7 @@ describe("Vitals Card component", () => {
|
||||
expect(screen.getByText("Enrollment ID")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("enrollment-id-12345")[0]).toBeInTheDocument();
|
||||
expect(screen.getByText("Hardware model")).toBeInTheDocument();
|
||||
expect(screen.getByText("MacBook Pro")).toBeInTheDocument();
|
||||
expect(screen.getByText("MacBook Pro (16-inch, 2021)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Private IP address")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("192.168.1.1")[0]).toBeInTheDocument();
|
||||
expect(screen.getByText("Public IP address")).toBeInTheDocument();
|
||||
@@ -166,7 +175,8 @@ describe("Vitals Card component", () => {
|
||||
it("render Hardware model, IP addresses, and Serial number for all non android and ios/ipad hosts that have enrolled not enrolled in MDM", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "darwin",
|
||||
hardware_model: "MacBook Pro",
|
||||
hardware_model: "MacBookPro18,1",
|
||||
hardware_marketing_name: "MacBook Pro (16-inch, 2021)",
|
||||
hardware_serial: "test-serial-number",
|
||||
primary_ip: "192.168.1.1",
|
||||
public_ip: "203.0.113.1",
|
||||
@@ -177,7 +187,7 @@ describe("Vitals Card component", () => {
|
||||
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
|
||||
|
||||
expect(screen.getByText("Hardware model")).toBeInTheDocument();
|
||||
expect(screen.getByText("MacBook Pro")).toBeInTheDocument();
|
||||
expect(screen.getByText("MacBook Pro (16-inch, 2021)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Private IP address")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("192.168.1.1")[0]).toBeInTheDocument();
|
||||
expect(screen.getByText("Public IP address")).toBeInTheDocument();
|
||||
@@ -190,7 +200,8 @@ describe("Vitals Card component", () => {
|
||||
it("render Hardware model, IP addresses, and Serial number for all non android and ios/ipad hosts that have manually enrolled in MDM", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "darwin",
|
||||
hardware_model: "MacBook Pro",
|
||||
hardware_model: "MacBookPro18,1",
|
||||
hardware_marketing_name: "MacBook Pro (16-inch, 2021)",
|
||||
hardware_serial: "test-serial-number",
|
||||
primary_ip: "192.168.1.1",
|
||||
public_ip: "203.0.113.1",
|
||||
@@ -203,7 +214,7 @@ describe("Vitals Card component", () => {
|
||||
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
|
||||
|
||||
expect(screen.getByText("Hardware model")).toBeInTheDocument();
|
||||
expect(screen.getByText("MacBook Pro")).toBeInTheDocument();
|
||||
expect(screen.getByText("MacBook Pro (16-inch, 2021)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Private IP address")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("192.168.1.1")[0]).toBeInTheDocument();
|
||||
expect(screen.getByText("Public IP address")).toBeInTheDocument();
|
||||
@@ -216,7 +227,8 @@ describe("Vitals Card component", () => {
|
||||
it("render Hardware model, IP addresses, and Serial number for all non android and ios/ipad hosts that have automatically enrolled in MDM", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "darwin",
|
||||
hardware_model: "MacBook Pro",
|
||||
hardware_model: "MacBookPro18,1",
|
||||
hardware_marketing_name: "MacBook Pro (16-inch, 2021)",
|
||||
hardware_serial: "test-serial-number",
|
||||
primary_ip: "192.168.1.1",
|
||||
public_ip: "203.0.113.1",
|
||||
@@ -229,7 +241,7 @@ describe("Vitals Card component", () => {
|
||||
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
|
||||
|
||||
expect(screen.getByText("Hardware model")).toBeInTheDocument();
|
||||
expect(screen.getByText("MacBook Pro")).toBeInTheDocument();
|
||||
expect(screen.getByText("MacBook Pro (16-inch, 2021)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Private IP address")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("192.168.1.1")[0]).toBeInTheDocument();
|
||||
expect(screen.getByText("Public IP address")).toBeInTheDocument();
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
removeOSPrefix,
|
||||
compareVersions,
|
||||
} from "utilities/helpers";
|
||||
import { getHardwareModelDisplay } from "pages/hosts/helpers";
|
||||
|
||||
import { HumanTimeDiffWithFleetLaunchCutoff } from "components/HumanTimeDiffWithDateTip";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
@@ -347,13 +348,24 @@ const Vitals = ({
|
||||
}
|
||||
|
||||
// Hardware model
|
||||
const hardwareModelDisplay = getHardwareModelDisplay(
|
||||
vitalsData.platform,
|
||||
vitalsData.hardware_model,
|
||||
vitalsData.hardware_marketing_name
|
||||
);
|
||||
vitals.push({
|
||||
sortKey: "Hardware model",
|
||||
element: (
|
||||
<DataSet
|
||||
key="hardware-model"
|
||||
title="Hardware model"
|
||||
value={<TooltipTruncatedText value={vitalsData.hardware_model} />}
|
||||
value={
|
||||
<TooltipTruncatedText
|
||||
value={hardwareModelDisplay.value}
|
||||
tooltip={hardwareModelDisplay.tooltip}
|
||||
alwaysShowTooltip={hardwareModelDisplay.alwaysShowTooltip}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
|
||||
|
||||
export const getHostStatusTooltipText = (status: string): string => {
|
||||
if (status === "online") {
|
||||
return "Online hosts will respond to a live report.";
|
||||
}
|
||||
if (status === DEFAULT_EMPTY_CELL_VALUE) {
|
||||
return "Device is pending enrollment in Apple Business and status is not yet available.";
|
||||
}
|
||||
return "Offline hosts won't respond to a live report because they may be shut down, asleep, or not connected to the internet.";
|
||||
};
|
||||
|
||||
export const getHostStatus = (
|
||||
status: string,
|
||||
mdmEnrollmentStatus?: string
|
||||
): string => {
|
||||
if (mdmEnrollmentStatus === "Pending") {
|
||||
return DEFAULT_EMPTY_CELL_VALUE;
|
||||
}
|
||||
|
||||
return status || DEFAULT_EMPTY_CELL_VALUE;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
|
||||
import { isAppleDevice } from "interfaces/platform";
|
||||
import { DEFAULT_EMPTY_CELL_VALUE } from "utilities/constants";
|
||||
|
||||
export const getHostStatusTooltipText = (status: string): string => {
|
||||
if (status === "online") {
|
||||
return "Online hosts will respond to a live report.";
|
||||
}
|
||||
if (status === DEFAULT_EMPTY_CELL_VALUE) {
|
||||
return "Device is pending enrollment in Apple Business and status is not yet available.";
|
||||
}
|
||||
return "Offline hosts won't respond to a live report because they may be shut down, asleep, or not connected to the internet.";
|
||||
};
|
||||
|
||||
export const getHostStatus = (
|
||||
status: string,
|
||||
mdmEnrollmentStatus?: string
|
||||
): string => {
|
||||
if (mdmEnrollmentStatus === "Pending") {
|
||||
return DEFAULT_EMPTY_CELL_VALUE;
|
||||
}
|
||||
|
||||
return status || DEFAULT_EMPTY_CELL_VALUE;
|
||||
};
|
||||
|
||||
// getHardwareModelDisplay computes how a host's hardware model is presented.
|
||||
// Apple devices with a known marketing name (e.g. "MacBook Pro (16-inch,
|
||||
// 2021)") show it in place of the raw model and reveal the raw model in a
|
||||
// tooltip; otherwise the raw model is shown with no supplemental tooltip.
|
||||
// An empty field may be "" (raw API data) or DEFAULT_EMPTY_CELL_VALUE (data
|
||||
// that went through normalizeEmptyValues) — both count as "no value".
|
||||
export const getHardwareModelDisplay = (
|
||||
platform: string,
|
||||
hardwareModel: string,
|
||||
hardwareMarketingName: string
|
||||
): { value: string; tooltip?: JSX.Element; alwaysShowTooltip: boolean } => {
|
||||
const isEmpty = (val: string) => !val || val === DEFAULT_EMPTY_CELL_VALUE;
|
||||
|
||||
const marketingName =
|
||||
isAppleDevice(platform) && !isEmpty(hardwareMarketingName)
|
||||
? hardwareMarketingName
|
||||
: "";
|
||||
// Only reveal the raw model on hover when we're actually showing a distinct
|
||||
// marketing name in its place. When there's no mapping the marketing name is
|
||||
// empty (or echoes the raw model), so we show the raw model plainly with no
|
||||
// tooltip.
|
||||
const showModelTooltip =
|
||||
!!marketingName &&
|
||||
!isEmpty(hardwareModel) &&
|
||||
marketingName !== hardwareModel;
|
||||
|
||||
return {
|
||||
value: marketingName || hardwareModel,
|
||||
tooltip: showModelTooltip ? (
|
||||
// Left-align to override the tooltip's default centered text.
|
||||
<div style={{ textAlign: "left" }}>
|
||||
<b>Model:</b> {hardwareModel}
|
||||
<br />
|
||||
<b>Marketing name:</b> {marketingName}
|
||||
</div>
|
||||
) : undefined,
|
||||
alwaysShowTooltip: showModelTooltip,
|
||||
};
|
||||
};
|
||||
@@ -449,6 +449,7 @@ export const HOST_VITALS_DATA = [
|
||||
"uptime",
|
||||
"last_enrolled_at",
|
||||
"hardware_model",
|
||||
"hardware_marketing_name",
|
||||
"hardware_serial",
|
||||
"primary_ip",
|
||||
"public_ip",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- Added new `apple_hardware_info` osquery extension table (macOS only) with a `marketing_name` column that returns the human-readable marketing name for the current Apple device.
|
||||
@@ -0,0 +1,46 @@
|
||||
//go:build darwin
|
||||
|
||||
package apple_hardware_info
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
osqclient "github.com/osquery/osquery-go"
|
||||
"github.com/osquery/osquery-go/plugin/table"
|
||||
)
|
||||
|
||||
// Columns defines the schema for the apple_hardware_info table.
|
||||
func Columns() []table.ColumnDefinition {
|
||||
return []table.ColumnDefinition{
|
||||
table.TextColumn("marketing_name"),
|
||||
}
|
||||
}
|
||||
|
||||
// Generate queries system_info for the hardware model and maps it to its marketing name.
|
||||
func Generate(ctx context.Context, _ table.QueryContext, socket string) ([]map[string]string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
c, err := osqclient.NewClient(socket, 2*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
row, err := c.QueryRowContext(ctx, "SELECT hardware_model FROM system_info")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
model := row["hardware_model"]
|
||||
|
||||
// Return an empty marketing_name when there's no mapping entry so a missing
|
||||
// mapping can be told apart from the raw model identifier.
|
||||
name := fleet.AppleHardwareModelsToMarketingNames[model]
|
||||
|
||||
return []map[string]string{{
|
||||
"marketing_name": name,
|
||||
}}, nil
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/table/adobe_plugins"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/table/app_sso_platform"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/table/apple_hardware_info"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/table/authdb"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/table/codesign"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/table/csrutil_info"
|
||||
@@ -54,6 +55,13 @@ func PlatformTables(opts PluginOpts) ([]osquery.OsqueryPlugin, error) {
|
||||
plugins := []osquery.OsqueryPlugin{
|
||||
// Fleet tables
|
||||
adobe_plugins.TablePlugin(log.Logger),
|
||||
table.NewPlugin(
|
||||
"apple_hardware_info",
|
||||
apple_hardware_info.Columns(),
|
||||
func(ctx context.Context, queryContext table.QueryContext) ([]map[string]string, error) {
|
||||
return apple_hardware_info.Generate(ctx, queryContext, opts.Socket)
|
||||
},
|
||||
),
|
||||
table.NewPlugin("icloud_private_relay", privaterelay.Columns(), privaterelay.Generate),
|
||||
table.NewPlugin("user_login_settings", user_login_settings.Columns(), user_login_settings.Generate),
|
||||
table.NewPlugin("pwd_policy", pwd_policy.Columns(), pwd_policy.Generate),
|
||||
|
||||
@@ -1157,6 +1157,26 @@
|
||||
"osqueryRepoUrl": "https://github.com/osquery/osquery/blob/master/specs/windows/appcompat_shims.table",
|
||||
"fleetRepoUrl": "https://github.com/fleetdm/fleet/new/main/schema?filename=tables%2Fappcompat_shims.yml&value=name%3A%20appcompat_shims%0Adescription%3A%20%7C-%20%23%20(required)%20string%20-%20The%20description%20for%20this%20table.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%23%20Add%20description%20here%0Aexamples%3A%20%7C-%20%23%20(optional)%20string%20-%20An%20example%20query%20for%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown%0A%09%23%20Add%20examples%20here%0Anotes%3A%20%7C-%20%23%20(optional)%20string%20-%20Notes%20about%20this%20table.%20Note%3A%20This%20field%20supports%20Markdown.%0A%09%23%20Add%20notes%20here%0Acolumns%3A%20%23%20(required)%0A%09-%20name%3A%20%23%20(required)%20string%20-%20The%20name%20of%20the%20column%0A%09%20%20description%3A%20%23%20(required)%20string%20-%20The%20column's%20description.%20Note%3A%20this%20field%20supports%20Markdown%0A%09%20%20type%3A%20%23%20(required)%20string%20-%20the%20column's%20data%20type%0A%09%20%20required%3A%20%23%20(required)%20boolean%20-%20whether%20or%20not%20this%20column%20is%20required%20to%20query%20this%20table."
|
||||
},
|
||||
{
|
||||
"name": "apple_hardware_info",
|
||||
"platforms": [
|
||||
"darwin"
|
||||
],
|
||||
"description": "Maps the Apple hardware model identifier to its marketing name.",
|
||||
"examples": "Get the marketing name for the current Mac.\n\n```\nSELECT marketing_name FROM apple_hardware_info;\n```\n\nJoin with `system_info` to get both the identifier and the marketing name.\n\n```\nSELECT si.hardware_model, ahi.marketing_name FROM system_info si, apple_hardware_info ahi;\n```",
|
||||
"columns": [
|
||||
{
|
||||
"name": "marketing_name",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"description": "The Apple marketing name for the hardware, e.g. MacBook Pro (16-inch, Nov 2023)."
|
||||
}
|
||||
],
|
||||
"notes": "This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)).",
|
||||
"evented": false,
|
||||
"url": "https://fleetdm.com/tables/apple_hardware_info",
|
||||
"fleetRepoUrl": "https://github.com/fleetdm/fleet/blob/main/schema/tables/apple_hardware_info.yml"
|
||||
},
|
||||
{
|
||||
"name": "apps",
|
||||
"description": "macOS applications installed in known search paths (e.g., /Applications).",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: apple_hardware_info
|
||||
platforms:
|
||||
- darwin
|
||||
description: Maps the Apple hardware model identifier to its marketing name.
|
||||
examples: |-
|
||||
Get the marketing name for the current Mac.
|
||||
|
||||
```
|
||||
SELECT marketing_name FROM apple_hardware_info;
|
||||
```
|
||||
|
||||
Join with `system_info` to get both the identifier and the marketing name.
|
||||
|
||||
```
|
||||
SELECT si.hardware_model, ahi.marketing_name FROM system_info si, apple_hardware_info ahi;
|
||||
```
|
||||
columns:
|
||||
- name: marketing_name
|
||||
type: text
|
||||
required: false
|
||||
description: "The Apple marketing name for the hardware, e.g. MacBook Pro (16-inch, Nov 2023)."
|
||||
notes: |-
|
||||
This table is not a core osquery table. It is included as part of Fleet's agent ([fleetd](https://fleetdm.com/docs/get-started/anatomy#fleetd)).
|
||||
evented: false
|
||||
@@ -0,0 +1,431 @@
|
||||
package fleet
|
||||
|
||||
// AppleHardwareModelsToMarketingNames' names are primarily sourced from SOFA's
|
||||
// device identifier data:
|
||||
// https://github.com/macadmins/sofa/blob/5a4fe284215d666a08d6ea9f66a554c90226b57c/data/models/device_identifiers.json.
|
||||
//
|
||||
// Add new entries here as Apple releases new devices.
|
||||
var AppleHardwareModelsToMarketingNames = map[string]string{
|
||||
"AppleTV1,1": "Apple TV (1st generation)",
|
||||
"AppleTV11,1": "Apple TV 4K (2nd generation)",
|
||||
"AppleTV14,1": "Apple TV 4K (3rd generation)",
|
||||
"AppleTV2,1": "Apple TV (2nd generation)",
|
||||
"AppleTV3,1": "Apple TV (3rd generation)",
|
||||
"AppleTV3,2": "Apple TV (3rd generation, Rev A)",
|
||||
"AppleTV5,3": "Apple TV HD (4th generation)",
|
||||
"AppleTV6,2": "Apple TV 4K (1st generation)",
|
||||
"Mac13,1": "Mac Studio (2022)",
|
||||
"Mac13,2": "Mac Studio (2022)",
|
||||
"Mac14,10": "MacBook Pro (16-inch, 2023)",
|
||||
"Mac14,12": "Mac Mini M2 Pro (2023)",
|
||||
"Mac14,13": "Mac Studio (2023)",
|
||||
"Mac14,14": "Mac Studio (2023)",
|
||||
"Mac14,15": "MacBook Air (15-inch, M2, 2023)",
|
||||
"Mac14,2": "MacBook Air (M2, 2022)",
|
||||
"Mac14,3": "Mac Mini M2 (2023)",
|
||||
"Mac14,5": "MacBook Pro (14-inch, 2023)",
|
||||
"Mac14,6": "MacBook Pro (16-inch, 2023)",
|
||||
"Mac14,7": "MacBook Pro (13-inch, M2, 2022)",
|
||||
"Mac14,8": "Mac Pro (2023)",
|
||||
"Mac14,9": "MacBook Pro (14-inch, 2023)",
|
||||
"Mac15,10": "MacBook Pro (14-inch, Nov 2023)",
|
||||
"Mac15,11": "MacBook Pro (16-inch, Nov 2023)",
|
||||
"Mac15,12": "MacBook Air (13-inch, M3, 2024)",
|
||||
"Mac15,13": "MacBook Air (15-inch, M3, 2024)",
|
||||
"Mac15,14": "Mac Studio (2025)",
|
||||
"Mac15,3": "MacBook Pro (14-inch, Nov 2023)",
|
||||
"Mac15,4": "iMac (24-inch, 2023, Two ports)",
|
||||
"Mac15,5": "iMac (24-inch, 2023, Four ports)",
|
||||
"Mac15,6": "MacBook Pro (14-inch, Nov 2023)",
|
||||
"Mac15,7": "MacBook Pro (16-inch, Nov 2023)",
|
||||
"Mac15,8": "MacBook Pro (14-inch, Nov 2023)",
|
||||
"Mac15,9": "MacBook Pro (16-inch, Nov 2023)",
|
||||
"Mac16,1": "MacBook Pro (14-inch, 2024)",
|
||||
"Mac16,10": "Mac Mini M4 (2024)",
|
||||
"Mac16,11": "Mac Mini M4 Pro (2024)",
|
||||
"Mac16,12": "MacBook Air (13-inch, M4, 2025)",
|
||||
"Mac16,13": "MacBook Air (15-inch, M4, 2025)",
|
||||
"Mac16,15": "Mac Mini M4 Pro (2024)",
|
||||
"Mac16,2": "iMac (24-inch, 2024, Two ports)",
|
||||
"Mac16,3": "iMac (24-inch, 2024, Four ports)",
|
||||
"Mac16,5": "MacBook Pro (16-inch, 2024)",
|
||||
"Mac16,6": "MacBook Pro (14-inch, 2024)",
|
||||
"Mac16,7": "MacBook Pro (16-inch, 2024)",
|
||||
"Mac16,8": "MacBook Pro (14-inch, 2024)",
|
||||
"Mac16,9": "Mac Studio (2025)",
|
||||
"Mac17,2": "MacBook Pro 14-inch (M5)",
|
||||
"Mac17,3": "MacBook Air (13-inch, M5)",
|
||||
"Mac17,4": "MacBook Air (15-inch, M5)",
|
||||
"Mac17,5": "MacBook Neo",
|
||||
"Mac17,6": "MacBook Pro (16-inch, M5 Max)",
|
||||
"Mac17,7": "MacBook Pro (14-inch, M5 Max)",
|
||||
"Mac17,8": "MacBook Pro (16-inch, M5 Pro)",
|
||||
"Mac17,9": "MacBook Pro (14-inch, M5 Pro)",
|
||||
"MacBook1,1": "MacBook (13-inch, Mid 2006)",
|
||||
"MacBook10,1": "MacBook (Retina, 12-inch, 2017)",
|
||||
"MacBook2,1": "MacBook (13-inch, Late 2006)",
|
||||
"MacBook3,1": "MacBook (13-inch, Late 2007)",
|
||||
"MacBook4,1": "MacBook (13-inch, Early 2008)",
|
||||
"MacBook5,1": "MacBook (13-inch, Aluminum, Late 2008)",
|
||||
"MacBook5,2": "MacBook (13-inch, Mid 2009)",
|
||||
"MacBook6,1": "MacBook (13-inch, Late 2009)",
|
||||
"MacBook7,1": "MacBook (13-inch, Mid 2010)",
|
||||
"MacBook8,1": "MacBook (Retina, 12-inch, Early 2015)",
|
||||
"MacBook9,1": "MacBook (Retina, 12-inch, Early 2016)",
|
||||
"MacBookAir1,1": "MacBook Air (Early 2008)",
|
||||
"MacBookAir10,1": "MacBook Air (M1, 2020)",
|
||||
"MacBookAir2,1": "MacBook Air (Mid 2009)",
|
||||
"MacBookAir3,1": "MacBook Air (11-inch, Late 2010)",
|
||||
"MacBookAir3,2": "MacBook Air (13-inch, Late 2010)",
|
||||
"MacBookAir4,1": "MacBook Air (11-inch, Mid 2011)",
|
||||
"MacBookAir4,2": "MacBook Air (13-inch, Mid 2011)",
|
||||
"MacBookAir5,1": "MacBook Air (11-inch, Mid 2012)",
|
||||
"MacBookAir5,2": "MacBook Air (13-inch, Mid 2012)",
|
||||
"MacBookAir6,1": "MacBook Air (11-inch, Mid 2013) / (11-inch, Early 2014)",
|
||||
"MacBookAir6,2": "MacBook Air (13-inch, Mid 2013) / (13-inch, Early 2014)",
|
||||
"MacBookAir7,1": "MacBook Air (11-inch, Early 2015)",
|
||||
"MacBookAir7,2": "MacBook Air (13-inch, Early 2015) / (13-inch, 2017)",
|
||||
"MacBookAir8,1": "MacBook Air (Retina, 13-inch, 2018)",
|
||||
"MacBookAir8,2": "MacBook Air (Retina, 13-inch, 2019)",
|
||||
"MacBookAir9,1": "MacBook Air (Retina, 13-inch, 2020)",
|
||||
"MacBookPro1,1": "MacBook Pro (15-inch, Early 2006)",
|
||||
"MacBookPro1,2": "MacBook Pro (17-inch, Early 2006)",
|
||||
"MacBookPro10,1": "MacBook Pro (Retina, 15-inch, Mid 2012/Early 2013)",
|
||||
"MacBookPro10,2": "MacBook Pro (Retina, 13-inch, Late 2012/Early 2013)",
|
||||
"MacBookPro11,1": "MacBook Pro (Retina, 13-inch, Late 2013/Mid 2014)",
|
||||
"MacBookPro11,2": "MacBook Pro (Retina, 15-inch, Late 2013/Mid 2014)",
|
||||
"MacBookPro11,3": "MacBook Pro (Retina, 15-inch, Late 2013/Mid 2014)",
|
||||
"MacBookPro11,4": "MacBook Pro (Retina, 15-inch, Mid 2015)",
|
||||
"MacBookPro11,5": "MacBook Pro (Retina, 15-inch, Mid 2015)",
|
||||
"MacBookPro12,1": "MacBook Pro (Retina, 13-inch, Early 2015)",
|
||||
"MacBookPro13,1": "MacBook Pro (13-inch, 2016)",
|
||||
"MacBookPro13,2": "MacBook Pro (13-inch, 2016)",
|
||||
"MacBookPro13,3": "MacBook Pro (15-inch, 2016)",
|
||||
"MacBookPro14,1": "MacBook Pro (13-inch, 2017)",
|
||||
"MacBookPro14,2": "MacBook Pro (13-inch, 2017)",
|
||||
"MacBookPro14,3": "MacBook Pro (15-inch, 2017)",
|
||||
"MacBookPro15,1": "MacBook Pro (15-inch, 2018/2019)",
|
||||
"MacBookPro15,2": "MacBook Pro (13-inch, 2018/2019)",
|
||||
"MacBookPro15,3": "MacBook Pro (15-inch, 2019)",
|
||||
"MacBookPro15,4": "MacBook Pro (13-inch, 2019)",
|
||||
"MacBookPro16,1": "MacBook Pro (16-inch, 2019)",
|
||||
"MacBookPro16,2": "MacBook Pro (13-inch, 2020)",
|
||||
"MacBookPro16,3": "MacBook Pro (13-inch, 2020)",
|
||||
"MacBookPro16,4": "MacBook Pro (16-inch, 2019)",
|
||||
"MacBookPro17,1": "MacBook Pro (13-inch, M1, 2020)",
|
||||
"MacBookPro18,1": "MacBook Pro (16-inch, 2021)",
|
||||
"MacBookPro18,2": "MacBook Pro (16-inch, 2021)",
|
||||
"MacBookPro18,3": "MacBook Pro (14-inch, 2021)",
|
||||
"MacBookPro18,4": "MacBook Pro (14-inch, 2021)",
|
||||
"MacBookPro2,1": "MacBook Pro (17-inch, Late 2006)",
|
||||
"MacBookPro2,2": "MacBook Pro (15-inch, Late 2006)",
|
||||
"MacBookPro3,1": "MacBook Pro (15-inch, Mid 2007)",
|
||||
"MacBookPro4,1": "MacBook Pro (Early 2008)",
|
||||
"MacBookPro5,1": "MacBook Pro (Late 2008)",
|
||||
"MacBookPro5,2": "MacBook Pro (Early/Mid 2009)",
|
||||
"MacBookPro5,3": "MacBook Pro (Mid 2009)",
|
||||
"MacBookPro5,4": "MacBook Pro (15-inch, Mid 2009, Integrated Graphics)",
|
||||
"MacBookPro5,5": "MacBook Pro (13-inch, Mid 2009)",
|
||||
"MacBookPro6,1": "MacBook Pro (17-inch, Mid 2010)",
|
||||
"MacBookPro6,2": "MacBook Pro (15-inch, Mid 2010)",
|
||||
"MacBookPro7,1": "MacBook Pro (13-inch, Mid 2010)",
|
||||
"MacBookPro8,1": "MacBook Pro (13-inch, Early/Late 2011)",
|
||||
"MacBookPro8,2": "MacBook Pro (15-inch, Early/Late 2011)",
|
||||
"MacBookPro8,3": "MacBook Pro (17-inch, Early/Late 2011)",
|
||||
"MacBookPro9,1": "MacBook Pro (15-inch, Mid 2012)",
|
||||
"MacBookPro9,2": "MacBook Pro (13-inch, Mid 2012)",
|
||||
"MacPro1,1": "Mac Pro (Mid 2006)",
|
||||
"MacPro2,1": "Mac Pro (Early 2007)",
|
||||
"MacPro3,1": "Mac Pro (Early 2008)",
|
||||
"MacPro4,1": "Mac Pro (Early 2009)",
|
||||
"MacPro5,1": "Mac Pro (Mid 2010/Mid 2012)",
|
||||
"MacPro6,1": "Mac Pro (Late 2013)",
|
||||
"MacPro7,1": "Mac Pro (2019)",
|
||||
"Macmini1,1": "Mac Mini (2006)",
|
||||
"Macmini2,1": "Mac Mini (2007)",
|
||||
"Macmini3,1": "Mac Mini (2009)",
|
||||
"Macmini4,1": "Mac Mini (Mid 2010)",
|
||||
"Macmini5,1": "Mac Mini (Mid 2011)",
|
||||
"Macmini5,2": "Mac Mini (Mid 2011)",
|
||||
"Macmini5,3": "Mac Mini (Mid 2011)",
|
||||
"Macmini6,1": "Mac Mini (Late 2012)",
|
||||
"Macmini6,2": "Mac Mini (Late 2012)",
|
||||
"Macmini7,1": "Mac Mini (Late 2014)",
|
||||
"Macmini8,1": "Mac Mini (2018)",
|
||||
"Macmini9,1": "Mac Mini M1 (2020)",
|
||||
"RealityDevice14,1": "Vision Pro",
|
||||
"RealityDevice17,1": "Vision Pro (M5)",
|
||||
"VirtualMac1,1": "Apple Virtual Machine",
|
||||
"VirtualMac2,1": "Apple Virtual Machine",
|
||||
"VirtualMac2,3": "Apple Virtual Machine",
|
||||
"Watch1,1": "Apple Watch (1st generation) (38mm)",
|
||||
"Watch1,2": "Apple Watch (1st generation) (42mm)",
|
||||
"Watch2,3": "Apple Watch Series 2 (38mm)",
|
||||
"Watch2,4": "Apple Watch Series 2 (42mm)",
|
||||
"Watch2,6": "Apple Watch Series 1 (38mm)",
|
||||
"Watch2,7": "Apple Watch Series 1 (42mm)",
|
||||
"Watch3,1": "Apple Watch Series 3 (GPS + Cellular, 38mm)",
|
||||
"Watch3,2": "Apple Watch Series 3 (GPS + Cellular, 42mm)",
|
||||
"Watch3,3": "Apple Watch Series 3 (GPS, 38mm)",
|
||||
"Watch3,4": "Apple Watch Series 3 (GPS, 42mm)",
|
||||
"Watch4,1": "Apple Watch Series 4 (GPS, 40mm)",
|
||||
"Watch4,2": "Apple Watch Series 4 (GPS, 44mm)",
|
||||
"Watch4,3": "Apple Watch Series 4 (GPS + Cellular, 40mm)",
|
||||
"Watch4,4": "Apple Watch Series 4 (GPS + Cellular, 44mm)",
|
||||
"Watch5,1": "Apple Watch Series 5 (GPS, 40mm)",
|
||||
"Watch5,10": "Apple Watch SE (1st generation, GPS, 44mm)",
|
||||
"Watch5,11": "Apple Watch SE (1st generation, GPS + Cellular, 40mm)",
|
||||
"Watch5,12": "Apple Watch SE (1st generation, GPS + Cellular, 44mm)",
|
||||
"Watch5,2": "Apple Watch Series 5 (GPS, 44mm)",
|
||||
"Watch5,3": "Apple Watch Series 5 (GPS + Cellular, 40mm)",
|
||||
"Watch5,4": "Apple Watch Series 5 (GPS + Cellular, 44mm)",
|
||||
"Watch5,9": "Apple Watch SE (1st generation, GPS, 40mm)",
|
||||
"Watch6,1": "Apple Watch Series 6 (GPS, 40mm)",
|
||||
"Watch6,10": "Apple Watch SE (2nd generation, GPS, 40mm)",
|
||||
"Watch6,11": "Apple Watch SE (2nd generation, GPS, 44mm)",
|
||||
"Watch6,12": "Apple Watch SE (2nd generation, GPS + Cellular, 40mm)",
|
||||
"Watch6,13": "Apple Watch SE (2nd generation, GPS + Cellular, 44mm)",
|
||||
"Watch6,14": "Apple Watch Series 8 (GPS, 45mm)",
|
||||
"Watch6,15": "Apple Watch Series 8 (GPS, 41mm)",
|
||||
"Watch6,16": "Apple Watch Series 8 (GPS + Cellular, 41mm)",
|
||||
"Watch6,17": "Apple Watch Series 8 (GPS + Cellular, 45mm)",
|
||||
"Watch6,18": "Apple Watch Ultra",
|
||||
"Watch6,2": "Apple Watch Series 6 (GPS, 44mm)",
|
||||
"Watch6,3": "Apple Watch Series 6 (GPS + Cellular, 40mm)",
|
||||
"Watch6,4": "Apple Watch Series 6 (GPS + Cellular, 44mm)",
|
||||
"Watch6,6": "Apple Watch Series 7 (GPS, 41mm)",
|
||||
"Watch6,7": "Apple Watch Series 7 (GPS, 45mm)",
|
||||
"Watch6,8": "Apple Watch Series 7 (GPS + Cellular, 41mm)",
|
||||
"Watch6,9": "Apple Watch Series 7 (GPS + Cellular, 45mm)",
|
||||
"Watch7,1": "Apple Watch Series 9 (GPS, 41mm)",
|
||||
"Watch7,10": "Apple Watch Series 10 (GPS + Cellular, 42mm)",
|
||||
"Watch7,11": "Apple Watch Series 10 (GPS + Cellular, 46mm)",
|
||||
"Watch7,12": "Apple Watch Ultra 3",
|
||||
"Watch7,13": "Apple Watch SE 3 (GPS, 40mm)",
|
||||
"Watch7,14": "Apple Watch SE 3 (GPS, 44mm)",
|
||||
"Watch7,15": "Apple Watch SE 3 (GPS + Cellular, 40mm)",
|
||||
"Watch7,16": "Apple Watch SE 3 (GPS + Cellular, 44mm)",
|
||||
"Watch7,17": "Apple Watch Series 11 (GPS, 42mm)",
|
||||
"Watch7,18": "Apple Watch Series 11 (GPS, 46mm)",
|
||||
"Watch7,19": "Apple Watch Series 11 (GPS + Cellular, 42mm)",
|
||||
"Watch7,2": "Apple Watch Series 9 (GPS, 45mm)",
|
||||
"Watch7,20": "Apple Watch Series 11 (GPS + Cellular, 46mm)",
|
||||
"Watch7,3": "Apple Watch Series 9 (GPS + Cellular, 41mm)",
|
||||
"Watch7,4": "Apple Watch Series 9 (GPS + Cellular, 45mm)",
|
||||
"Watch7,5": "Apple Watch Ultra 2",
|
||||
"Watch7,8": "Apple Watch Series 10 (GPS, 42mm)",
|
||||
"Watch7,9": "Apple Watch Series 10 (GPS, 46mm)",
|
||||
"Xserve1,1": "Xserve (Late 2006)",
|
||||
"Xserve2,1": "Xserve (Early 2008)",
|
||||
"Xserve3,1": "Xserve (Early 2009)",
|
||||
"iMac10,1": "iMac (21.5-inch, Late 2009)",
|
||||
"iMac11,1": "iMac (27-inch, Late 2009, Core i5/i7)",
|
||||
"iMac11,2": "iMac (21.5-inch, Mid 2010)",
|
||||
"iMac11,3": "iMac (27-inch, Mid 2010)",
|
||||
"iMac12,1": "iMac (21.5-inch, Mid 2011)",
|
||||
"iMac12,2": "iMac (27-inch, Mid 2011)",
|
||||
"iMac13,1": "iMac (21.5-inch, Late 2012)",
|
||||
"iMac13,2": "iMac (27-inch, Late 2012)",
|
||||
"iMac13,3": "iMac (21.5-inch, Early 2013)",
|
||||
"iMac14,1": "iMac (21.5-inch, Late 2013, Integrated Graphics)",
|
||||
"iMac14,2": "iMac (27-inch, Late 2013)",
|
||||
"iMac14,3": "iMac (21.5-inch, Late 2013, Dedicated Graphics)",
|
||||
"iMac14,4": "iMac (21.5-inch, Mid 2014)",
|
||||
"iMac15,1": "iMac (Retina 5K, 27-inch, Mid 2015)",
|
||||
"iMac16,1": "iMac (21.5-inch, Late 2015)",
|
||||
"iMac16,2": "iMac (21.5-inch, Late 2015)",
|
||||
"iMac17,1": "iMac (Retina 5K, 27-inch, Late 2015)",
|
||||
"iMac18,1": "iMac (21.5-inch, 2017)",
|
||||
"iMac18,2": "iMac (Retina 4K, 21.5-inch, 2017)",
|
||||
"iMac18,3": "iMac (Retina 5K, 27-inch, 2017)",
|
||||
"iMac19,1": "iMac (Retina 5K, 27-inch, 2019)",
|
||||
"iMac19,2": "iMac (Retina 4K, 21.5-inch, 2019)",
|
||||
"iMac20,1": "iMac (Retina 5K, 27-inch, 2020)",
|
||||
"iMac20,2": "iMac (Retina 5K, 27-inch, 2020, RX 5700/XT)",
|
||||
"iMac21,1": "iMac (24-inch, M1, 2021)",
|
||||
"iMac21,2": "iMac (24-inch, M1, 2021)",
|
||||
"iMac4,1": "iMac (17-inch, Early 2006)",
|
||||
"iMac4,2": "iMac (17-inch, Mid 2006)",
|
||||
"iMac5,1": "iMac (17-inch, Late 2006, Dedicated Graphics)",
|
||||
"iMac5,2": "iMac (17-inch, Late 2006, Integrated Graphics)",
|
||||
"iMac6,1": "iMac (24-inch, Late 2006)",
|
||||
"iMac7,1": "iMac (24-inch, Mid 2007)",
|
||||
"iMac8,1": "iMac (24-inch, Early 2008)",
|
||||
"iMac9,1": "iMac (20-inch, Mid 2009)",
|
||||
"iMacPro1,1": "iMac Pro (Retina 5K, 27-inch, Late 2017)",
|
||||
"iPad1,1": "iPad",
|
||||
"iPad11,1": "iPad mini (5th generation) Wi-Fi",
|
||||
"iPad11,2": "iPad mini (5th generation) Wi-Fi + Cellular",
|
||||
"iPad11,3": "iPad Air (3rd generation) Wi-Fi",
|
||||
"iPad11,4": "iPad Air (3rd generation) Wi-Fi + Cellular",
|
||||
"iPad11,6": "iPad (8th generation) Wi-Fi",
|
||||
"iPad11,7": "iPad (8th generation) Wi-Fi + Cellular",
|
||||
"iPad12,1": "iPad (9th generation) Wi-Fi",
|
||||
"iPad12,2": "iPad (9th generation) Wi-Fi + Cellular",
|
||||
"iPad13,1": "iPad Air (4th generation) Wi-Fi",
|
||||
"iPad13,10": "iPad Pro 12.9-inch (5th generation) Wi-Fi + Cellular",
|
||||
"iPad13,11": "iPad Pro 12.9-inch (5th generation) Wi-Fi + Cellular (1 or 2 TB)",
|
||||
"iPad13,16": "iPad Air (5th generation) Wi-Fi",
|
||||
"iPad13,17": "iPad Air (5th generation) Wi-Fi + Cellular",
|
||||
"iPad13,18": "iPad (10th generation) Wi-Fi",
|
||||
"iPad13,19": "iPad (10th generation) Wi-Fi + Cellular",
|
||||
"iPad13,2": "iPad Air (4th generation) Wi-Fi + Cellular",
|
||||
"iPad13,4": "iPad Pro 11-inch (3rd generation) Wi-Fi",
|
||||
"iPad13,5": "iPad Pro 11-inch (3rd generation) Wi-Fi (1 or 2 TB)",
|
||||
"iPad13,6": "iPad Pro 11-inch (3rd generation) Wi-Fi + Cellular",
|
||||
"iPad13,7": "iPad Pro 11-inch (3rd generation) Wi-Fi + Cellular (1 or 2 TB)",
|
||||
"iPad13,8": "iPad Pro 12.9-inch (5th generation) Wi-Fi",
|
||||
"iPad13,9": "iPad Pro 12.9-inch (5th generation) Wi-Fi (1 or 2 TB)",
|
||||
"iPad14,1": "iPad mini (6th generation) Wi-Fi",
|
||||
"iPad14,10": "iPad Air 13-inch (M2) Wi-Fi",
|
||||
"iPad14,11": "iPad Air 13-inch (M2) Wi-Fi + Cellular",
|
||||
"iPad14,2": "iPad mini (6th generation) Wi-Fi + Cellular",
|
||||
"iPad14,3": "iPad Pro 11-inch (4th generation) Wi-Fi",
|
||||
"iPad14,4": "iPad Pro 11-inch (4th generation) Wi-Fi + Cellular",
|
||||
"iPad14,5": "iPad Pro 12.9-inch (6th generation) Wi-Fi",
|
||||
"iPad14,6": "iPad Pro 12.9-inch (6th generation) Wi-Fi + Cellular",
|
||||
"iPad14,8": "iPad Air 11-inch (M2) Wi-Fi",
|
||||
"iPad14,9": "iPad Air 11-inch (M2) Wi-Fi + Cellular",
|
||||
"iPad15,3": "iPad Air 11-inch (M3) Wi-Fi",
|
||||
"iPad15,4": "iPad Air 11-inch (M3) Wi-Fi + Cellular",
|
||||
"iPad15,5": "iPad Air 13-inch (M3) Wi-Fi",
|
||||
"iPad15,6": "iPad Air 13-inch (M3) Wi-Fi + Cellular",
|
||||
"iPad15,7": "iPad (A16) Wi-Fi",
|
||||
"iPad15,8": "iPad (A16) Wi-Fi + Cellular",
|
||||
"iPad16,1": "iPad mini (A17 Pro) Wi-Fi",
|
||||
"iPad16,10": "iPad Air 13-inch (M4) Wi-Fi",
|
||||
"iPad16,11": "iPad Air 13-inch (M4) Wi-Fi + Cellular",
|
||||
"iPad16,2": "iPad mini (A17 Pro) Wi-Fi + Cellular",
|
||||
"iPad16,3": "iPad Pro 11-inch (M4) Wi-Fi",
|
||||
"iPad16,4": "iPad Pro 11-inch (M4) Wi-Fi + Cellular",
|
||||
"iPad16,5": "iPad Pro 13-inch (M4) Wi-Fi",
|
||||
"iPad16,6": "iPad Pro 13-inch (M4) Wi-Fi + Cellular",
|
||||
"iPad16,8": "iPad Air 11-inch (M4) Wi-Fi",
|
||||
"iPad16,9": "iPad Air 11-inch (M4) Wi-Fi + Cellular",
|
||||
"iPad17,1": "iPad Pro 11-inch Wi-Fi (M5)",
|
||||
"iPad17,2": "iPad Pro 11-inch Wi-Fi + Cellular (M5)",
|
||||
"iPad17,3": "iPad Pro 13-inch Wi-Fi (M5)",
|
||||
"iPad17,4": "iPad Pro 13-inch Wi-Fi + Cellular (M5)",
|
||||
"iPad2,1": "iPad 2 Wi-Fi",
|
||||
"iPad2,2": "iPad 2 Wi-Fi + 3G (GSM)",
|
||||
"iPad2,3": "iPad 2 Wi-Fi + 3G (CDMA)",
|
||||
"iPad2,4": "iPad 2 Wi-Fi (Mid 2012)",
|
||||
"iPad2,5": "iPad mini Wi-Fi",
|
||||
"iPad2,6": "iPad mini Wi-Fi + Cellular",
|
||||
"iPad2,7": "iPad mini Wi-Fi + Cellular (MM)",
|
||||
"iPad3,1": "iPad (3rd generation) Wi-Fi",
|
||||
"iPad3,2": "iPad (3rd generation) Wi-Fi + Cellular (VZ)",
|
||||
"iPad3,3": "iPad (3rd generation) Wi-Fi + Cellular",
|
||||
"iPad3,4": "iPad (4th generation) Wi-Fi",
|
||||
"iPad3,5": "iPad (4th generation) Wi-Fi + Cellular",
|
||||
"iPad3,6": "iPad (4th generation) Wi-Fi + Cellular (MM)",
|
||||
"iPad4,1": "iPad Air Wi-Fi",
|
||||
"iPad4,2": "iPad Air Wi-Fi + Cellular (GSM/CDMA)",
|
||||
"iPad4,3": "iPad Air Wi-Fi + Cellular (TD-LTE)",
|
||||
"iPad4,4": "iPad mini 2 Wi-Fi",
|
||||
"iPad4,5": "iPad mini 2 Wi-Fi + Cellular",
|
||||
"iPad4,6": "iPad mini 2 Wi-Fi + Cellular (TD-LTE)",
|
||||
"iPad4,7": "iPad mini 3 Wi-Fi",
|
||||
"iPad4,8": "iPad mini 3 Wi-Fi + Cellular",
|
||||
"iPad4,9": "iPad mini 3 Wi-Fi + Cellular (TD-LTE)",
|
||||
"iPad5,1": "iPad mini 4 Wi-Fi",
|
||||
"iPad5,2": "iPad mini 4 Wi-Fi + Cellular",
|
||||
"iPad5,3": "iPad Air 2 Wi-Fi",
|
||||
"iPad5,4": "iPad Air 2 Wi-Fi + Cellular",
|
||||
"iPad6,11": "iPad (5th generation) Wi-Fi",
|
||||
"iPad6,12": "iPad (5th generation) Wi-Fi + Cellular",
|
||||
"iPad6,3": "iPad Pro (9.7-inch) Wi-Fi",
|
||||
"iPad6,4": "iPad Pro (9.7-inch) Wi-Fi + Cellular",
|
||||
"iPad6,7": "iPad Pro (12.9-inch) (1st generation) Wi-Fi",
|
||||
"iPad6,8": "iPad Pro (12.9-inch) (1st generation) Wi-Fi + Cellular",
|
||||
"iPad7,1": "iPad Pro 12.9-inch (2nd generation) Wi-Fi",
|
||||
"iPad7,11": "iPad (7th generation) Wi-Fi",
|
||||
"iPad7,12": "iPad (7th generation) Wi-Fi + Cellular",
|
||||
"iPad7,2": "iPad Pro 12.9-inch (2nd generation) Wi-Fi + Cellular",
|
||||
"iPad7,3": "iPad Pro (10.5-inch) Wi-Fi",
|
||||
"iPad7,4": "iPad Pro (10.5-inch) Wi-Fi + Cellular",
|
||||
"iPad7,5": "iPad (6th generation) Wi-Fi",
|
||||
"iPad7,6": "iPad (6th generation) Wi-Fi + Cellular",
|
||||
"iPad8,1": "iPad Pro 11-inch (1st generation) Wi-Fi",
|
||||
"iPad8,10": "iPad Pro 11-inch (2nd generation) Wi-Fi + Cellular",
|
||||
"iPad8,11": "iPad Pro 12.9-inch (4th generation) Wi-Fi",
|
||||
"iPad8,12": "iPad Pro 12.9-inch (4th generation) Wi-Fi + Cellular",
|
||||
"iPad8,2": "iPad Pro 11-inch (1st generation) Wi-Fi (1TB)",
|
||||
"iPad8,3": "iPad Pro 11-inch (1st generation) Wi-Fi + Cellular",
|
||||
"iPad8,4": "iPad Pro 11-inch (1st generation) Wi-Fi + Cellular (1TB)",
|
||||
"iPad8,5": "iPad Pro 12.9-inch (3rd generation) Wi-Fi",
|
||||
"iPad8,6": "iPad Pro 12.9-inch (3rd generation) Wi-Fi (1TB)",
|
||||
"iPad8,7": "iPad Pro 12.9-inch (3rd generation) Wi-Fi + Cellular",
|
||||
"iPad8,8": "iPad Pro 12.9-inch (3rd generation) Wi-Fi + Cellular (1TB)",
|
||||
"iPad8,9": "iPad Pro 11-inch (2nd generation) Wi-Fi",
|
||||
"iPhone1,1": "iPhone",
|
||||
"iPhone1,2": "iPhone 3G",
|
||||
"iPhone10,1": "iPhone 8 (CDMA)",
|
||||
"iPhone10,2": "iPhone 8 Plus (CDMA)",
|
||||
"iPhone10,3": "iPhone X (CDMA)",
|
||||
"iPhone10,4": "iPhone 8 (GSM)",
|
||||
"iPhone10,5": "iPhone 8 Plus (GSM)",
|
||||
"iPhone10,6": "iPhone X (GSM)",
|
||||
"iPhone11,2": "iPhone XS",
|
||||
"iPhone11,4": "iPhone XS Max (China mainland)",
|
||||
"iPhone11,6": "iPhone XS Max",
|
||||
"iPhone11,8": "iPhone XR",
|
||||
"iPhone12,1": "iPhone 11",
|
||||
"iPhone12,3": "iPhone 11 Pro",
|
||||
"iPhone12,5": "iPhone 11 Pro Max",
|
||||
"iPhone12,8": "iPhone SE (2nd generation)",
|
||||
"iPhone13,1": "iPhone 12 mini",
|
||||
"iPhone13,2": "iPhone 12",
|
||||
"iPhone13,3": "iPhone 12 Pro",
|
||||
"iPhone13,4": "iPhone 12 Pro Max",
|
||||
"iPhone14,2": "iPhone 13 Pro",
|
||||
"iPhone14,3": "iPhone 13 Pro Max",
|
||||
"iPhone14,4": "iPhone 13 mini",
|
||||
"iPhone14,5": "iPhone 13",
|
||||
"iPhone14,6": "iPhone SE (3rd generation)",
|
||||
"iPhone14,7": "iPhone 14",
|
||||
"iPhone14,8": "iPhone 14 Plus",
|
||||
"iPhone15,2": "iPhone 14 Pro",
|
||||
"iPhone15,3": "iPhone 14 Pro Max",
|
||||
"iPhone15,4": "iPhone 15",
|
||||
"iPhone15,5": "iPhone 15 Plus",
|
||||
"iPhone16,1": "iPhone 15 Pro",
|
||||
"iPhone16,2": "iPhone 15 Pro Max",
|
||||
"iPhone17,1": "iPhone 16 Pro",
|
||||
"iPhone17,2": "iPhone 16 Pro Max",
|
||||
"iPhone17,3": "iPhone 16",
|
||||
"iPhone17,4": "iPhone 16 Plus",
|
||||
"iPhone17,5": "iPhone 16e",
|
||||
"iPhone18,1": "iPhone 17 Pro",
|
||||
"iPhone18,2": "iPhone 17 Pro Max",
|
||||
"iPhone18,3": "iPhone 17",
|
||||
"iPhone18,4": "iPhone Air",
|
||||
"iPhone18,5": "iPhone 17e",
|
||||
"iPhone2,1": "iPhone 3GS",
|
||||
"iPhone3,1": "iPhone 4 (GSM)",
|
||||
"iPhone3,2": "iPhone 4 (GSM, 2012)",
|
||||
"iPhone3,3": "iPhone 4 (CDMA)",
|
||||
"iPhone4,1": "iPhone 4s",
|
||||
"iPhone5,1": "iPhone 5 (GSM)",
|
||||
"iPhone5,2": "iPhone 5 (CDMA)",
|
||||
"iPhone5,3": "iPhone 5c (GSM)",
|
||||
"iPhone5,4": "iPhone 5c (CDMA)",
|
||||
"iPhone6,1": "iPhone 5s (GSM)",
|
||||
"iPhone6,2": "iPhone 5s (CDMA)",
|
||||
"iPhone7,1": "iPhone 6 Plus",
|
||||
"iPhone7,2": "iPhone 6",
|
||||
"iPhone8,1": "iPhone 6s",
|
||||
"iPhone8,2": "iPhone 6s Plus",
|
||||
"iPhone8,4": "iPhone SE (1st generation)",
|
||||
"iPhone9,1": "iPhone 7 (CDMA)",
|
||||
"iPhone9,2": "iPhone 7 Plus (CDMA)",
|
||||
"iPhone9,3": "iPhone 7 (GSM)",
|
||||
"iPhone9,4": "iPhone 7 Plus (GSM)",
|
||||
"iPod1,1": "iPod touch (1st generation)",
|
||||
"iPod2,1": "iPod touch (2nd generation)",
|
||||
"iPod3,1": "iPod touch (3rd generation)",
|
||||
"iPod4,1": "iPod touch (4th generation)",
|
||||
"iPod5,1": "iPod touch (5th generation)",
|
||||
"iPod7,1": "iPod touch (6th generation)",
|
||||
"iPod9,1": "iPod touch (7th generation)",
|
||||
}
|
||||
@@ -12,14 +12,15 @@ type HostResponse struct {
|
||||
*Host
|
||||
// Add alias fields for team name and ID for use in CSV reports.
|
||||
// TODO: clean up in Fleet 5.
|
||||
FleetID *uint `json:"-" csv:"fleet_id"`
|
||||
FleetName *string `json:"-" csv:"fleet_name"`
|
||||
Status HostStatus `json:"status" csv:"status"`
|
||||
DisplayText string `json:"display_text" csv:"display_text"`
|
||||
DisplayName string `json:"display_name" csv:"display_name"`
|
||||
Labels []*Label `json:"labels,omitempty" csv:"-"`
|
||||
Geolocation *GeoLocation `json:"geolocation,omitempty" csv:"-"`
|
||||
CSVDeviceMapping string `json:"-" db:"-" csv:"device_mapping"`
|
||||
FleetID *uint `json:"-" csv:"fleet_id"`
|
||||
FleetName *string `json:"-" csv:"fleet_name"`
|
||||
Status HostStatus `json:"status" csv:"status"`
|
||||
DisplayText string `json:"display_text" csv:"display_text"`
|
||||
DisplayName string `json:"display_name" csv:"display_name"`
|
||||
Labels []*Label `json:"labels,omitempty" csv:"-"`
|
||||
Geolocation *GeoLocation `json:"geolocation,omitempty" csv:"-"`
|
||||
CSVDeviceMapping string `json:"-" db:"-" csv:"device_mapping"`
|
||||
HardwareMarketingName string `json:"hardware_marketing_name" csv:"hardware_marketing_name"`
|
||||
}
|
||||
|
||||
// HostResponseForHost returns a HostResponse from Host with Geolocation.
|
||||
@@ -32,10 +33,11 @@ func HostResponseForHost(ctx context.Context, svc Service, host *Host) *HostResp
|
||||
// HostResponseForHostCheap returns a new HostResponse from a Host without computing Geolocation.
|
||||
func HostResponseForHostCheap(host *Host) *HostResponse {
|
||||
return &HostResponse{
|
||||
Host: host,
|
||||
Status: host.Status(time.Now()),
|
||||
DisplayText: host.Hostname,
|
||||
DisplayName: host.DisplayName(),
|
||||
Host: host,
|
||||
Status: host.Status(time.Now()),
|
||||
DisplayText: host.Hostname,
|
||||
DisplayName: host.DisplayName(),
|
||||
HardwareMarketingName: host.HardwareMarketingName(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +55,9 @@ func HostResponsesForHostsCheap(hosts []Host) []HostResponse {
|
||||
// with the HostDetail details.
|
||||
type HostDetailResponse struct {
|
||||
HostDetail
|
||||
Status HostStatus `json:"status"`
|
||||
DisplayText string `json:"display_text"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Geolocation *GeoLocation `json:"geolocation,omitempty"`
|
||||
Status HostStatus `json:"status"`
|
||||
DisplayText string `json:"display_text"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Geolocation *GeoLocation `json:"geolocation,omitempty"`
|
||||
HardwareMarketingName string `json:"hardware_marketing_name"`
|
||||
}
|
||||
|
||||
@@ -1082,6 +1082,19 @@ func (h *Host) DisplayName() string {
|
||||
return HostDisplayName(h.ComputerName, h.Hostname, h.HardwareModel, h.HardwareSerial)
|
||||
}
|
||||
|
||||
// HardwareMarketingName returns the Apple marketing name for the host's hardware
|
||||
// model (e.g. "MacBook Pro (16-inch, Nov 2023)"). It returns an empty string
|
||||
// when the platform is not an Apple platform or the identifier is not in the
|
||||
// mapping, so a missing mapping entry can be told apart from the raw model.
|
||||
func (h *Host) HardwareMarketingName() string {
|
||||
if IsApplePlatform(h.Platform) {
|
||||
if name, ok := AppleHardwareModelsToMarketingNames[h.HardwareModel]; ok {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (h *HostLite) DisplayName() string {
|
||||
return HostDisplayName(h.ComputerName, h.Hostname, h.HardwareModel, h.HardwareSerial)
|
||||
}
|
||||
|
||||
+94
-41
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"net/http"
|
||||
"reflect"
|
||||
@@ -74,11 +73,12 @@ func hostDetailResponseForHost(ctx context.Context, svc fleet.Service, host *fle
|
||||
}
|
||||
|
||||
return &fleet.HostDetailResponse{
|
||||
HostDetail: *host,
|
||||
Status: host.Status(time.Now()),
|
||||
DisplayText: host.Hostname,
|
||||
DisplayName: host.DisplayName(),
|
||||
Geolocation: geoLoc,
|
||||
HostDetail: *host,
|
||||
Status: host.Status(time.Now()),
|
||||
DisplayText: host.Hostname,
|
||||
DisplayName: host.DisplayName(),
|
||||
Geolocation: geoLoc,
|
||||
HardwareMarketingName: host.HardwareMarketingName(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3089,40 +3089,41 @@ func (r hostsReportResponse) HijackRender(ctx context.Context, w http.ResponseWr
|
||||
return
|
||||
}
|
||||
|
||||
// read back the CSV to reorder and (optionally) filter columns
|
||||
recs, err := csv.NewReader(&buf).ReadAll()
|
||||
if err != nil {
|
||||
logging.WithErr(ctx, err)
|
||||
encodeError(ctx, ctxerr.New(ctx, "failed to generate CSV file"), w)
|
||||
return
|
||||
}
|
||||
|
||||
returnAll := len(r.Columns) == 0
|
||||
|
||||
var outRows [][]string
|
||||
if !returnAll {
|
||||
// read back the CSV to filter out any unwanted columns
|
||||
recs, err := csv.NewReader(&buf).ReadAll()
|
||||
if err != nil {
|
||||
logging.WithErr(ctx, err)
|
||||
encodeError(ctx, ctxerr.New(ctx, "failed to generate CSV file"), w)
|
||||
return
|
||||
if returnAll {
|
||||
applyCSVColumnPlacements(recs)
|
||||
outRows = recs
|
||||
} else if len(recs) > 0 {
|
||||
// map the header names to their field index
|
||||
hdrs := make(map[string]int, len(recs[0]))
|
||||
for i, hdr := range recs[0] {
|
||||
hdrs[hdr] = i
|
||||
}
|
||||
|
||||
if len(recs) > 0 {
|
||||
// map the header names to their field index
|
||||
hdrs := make(map[string]int, len(recs))
|
||||
for i, hdr := range recs[0] {
|
||||
hdrs[hdr] = i
|
||||
}
|
||||
|
||||
outRows = make([][]string, len(recs))
|
||||
for i, rec := range recs {
|
||||
for _, col := range r.Columns {
|
||||
colIx, ok := hdrs[col]
|
||||
if !ok {
|
||||
// invalid column name - it would be nice to catch this in the
|
||||
// endpoint before processing the results, but it would require
|
||||
// duplicating the list of columns from the Host's struct tags to a
|
||||
// map and keep this in sync, for what is essentially a programmer
|
||||
// mistake that should be caught and corrected early.
|
||||
encodeError(ctx, &fleet.BadRequestError{Message: fmt.Sprintf("invalid column name: %q", col)}, w)
|
||||
return
|
||||
}
|
||||
outRows[i] = append(outRows[i], rec[colIx])
|
||||
outRows = make([][]string, len(recs))
|
||||
for i, rec := range recs {
|
||||
for _, col := range r.Columns {
|
||||
colIx, ok := hdrs[col]
|
||||
if !ok {
|
||||
// invalid column name - it would be nice to catch this in the
|
||||
// endpoint before processing the results, but it would require
|
||||
// duplicating the list of columns from the Host's struct tags to a
|
||||
// map and keep this in sync, for what is essentially a programmer
|
||||
// mistake that should be caught and corrected early.
|
||||
encodeError(ctx, &fleet.BadRequestError{Message: fmt.Sprintf("invalid column name: %q", col)}, w)
|
||||
return
|
||||
}
|
||||
outRows[i] = append(outRows[i], rec[colIx])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3132,17 +3133,69 @@ func (r hostsReportResponse) HijackRender(ctx context.Context, w http.ResponseWr
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
var err error
|
||||
if returnAll {
|
||||
_, err = io.Copy(w, &buf)
|
||||
} else {
|
||||
err = csv.NewWriter(w).WriteAll(outRows)
|
||||
}
|
||||
if err != nil {
|
||||
if err := csv.NewWriter(w).WriteAll(outRows); err != nil {
|
||||
logging.WithErr(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
// csvColumnPlacements forces the ordering of columns in the full (unfiltered)
|
||||
// hosts report CSV. gocsv appends HostResponse fields after all Host columns,
|
||||
// so columns whose documented position is elsewhere must be moved explicitly.
|
||||
// The filtered path already emits columns in the requested order.
|
||||
var csvColumnPlacements = []struct{ col, after string }{
|
||||
{"hardware_marketing_name", "hardware_model"},
|
||||
}
|
||||
|
||||
func applyCSVColumnPlacements(recs [][]string) {
|
||||
for _, p := range csvColumnPlacements {
|
||||
reorderCSVColumnAfter(recs, p.col, p.after)
|
||||
}
|
||||
}
|
||||
|
||||
// reorderCSVColumnAfter moves the column named col so that it immediately
|
||||
// follows the column named afterCol in every record (header + rows). It is a
|
||||
// no-op if either column is missing.
|
||||
func reorderCSVColumnAfter(recs [][]string, col, afterCol string) {
|
||||
if len(recs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
from, after := -1, -1
|
||||
for i, hdr := range recs[0] {
|
||||
switch hdr {
|
||||
case col:
|
||||
from = i
|
||||
case afterCol:
|
||||
after = i
|
||||
}
|
||||
}
|
||||
if from < 0 || after < 0 || from == after {
|
||||
return
|
||||
}
|
||||
|
||||
// Build the new column index order with `from` placed right after `after`.
|
||||
order := make([]int, 0, len(recs[0]))
|
||||
for i := range recs[0] {
|
||||
if i == from {
|
||||
continue
|
||||
}
|
||||
order = append(order, i)
|
||||
if i == after {
|
||||
order = append(order, from)
|
||||
}
|
||||
}
|
||||
|
||||
for r, rec := range recs {
|
||||
newRec := make([]string, len(order))
|
||||
for j, idx := range order {
|
||||
if idx < len(rec) {
|
||||
newRec[j] = rec[idx]
|
||||
}
|
||||
}
|
||||
recs[r] = newRec
|
||||
}
|
||||
}
|
||||
|
||||
func hostsReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
||||
req := request.(*hostsReportRequest)
|
||||
|
||||
|
||||
@@ -5705,3 +5705,134 @@ func TestGetHostDEPAssignmentDetailsNotFoundClassification(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderCSVColumnAfter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
recs [][]string
|
||||
col string
|
||||
afterCol string
|
||||
want [][]string
|
||||
}{
|
||||
{
|
||||
name: "moves a column that comes after its target",
|
||||
recs: [][]string{
|
||||
{"a", "model", "b", "c", "marketing"},
|
||||
{"1", "m1", "2", "3", "mk1"},
|
||||
},
|
||||
col: "marketing",
|
||||
afterCol: "model",
|
||||
want: [][]string{
|
||||
{"a", "model", "marketing", "b", "c"},
|
||||
{"1", "m1", "mk1", "2", "3"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reorders every data row",
|
||||
recs: [][]string{
|
||||
{"a", "model", "b", "marketing"},
|
||||
{"1", "m1", "2", "mk1"},
|
||||
{"3", "m2", "4", "mk2"},
|
||||
{"5", "m3", "6", "mk3"},
|
||||
},
|
||||
col: "marketing",
|
||||
afterCol: "model",
|
||||
want: [][]string{
|
||||
{"a", "model", "marketing", "b"},
|
||||
{"1", "m1", "mk1", "2"},
|
||||
{"3", "m2", "mk2", "4"},
|
||||
{"5", "m3", "mk3", "6"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "moves a column that comes before its target",
|
||||
recs: [][]string{
|
||||
{"marketing", "a", "model", "b"},
|
||||
{"mk1", "1", "m1", "2"},
|
||||
},
|
||||
col: "marketing",
|
||||
afterCol: "model",
|
||||
want: [][]string{
|
||||
{"a", "model", "marketing", "b"},
|
||||
{"1", "m1", "mk1", "2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "already immediately after target is unchanged",
|
||||
recs: [][]string{
|
||||
{"model", "marketing", "b"},
|
||||
{"m1", "mk1", "2"},
|
||||
},
|
||||
col: "marketing",
|
||||
afterCol: "model",
|
||||
want: [][]string{
|
||||
{"model", "marketing", "b"},
|
||||
{"m1", "mk1", "2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "header-only records are reordered",
|
||||
recs: [][]string{
|
||||
{"a", "marketing", "model", "b"},
|
||||
},
|
||||
col: "marketing",
|
||||
afterCol: "model",
|
||||
want: [][]string{
|
||||
{"a", "model", "marketing", "b"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing col is a no-op",
|
||||
recs: [][]string{
|
||||
{"model", "b"},
|
||||
{"m1", "2"},
|
||||
},
|
||||
col: "marketing",
|
||||
afterCol: "model",
|
||||
want: [][]string{
|
||||
{"model", "b"},
|
||||
{"m1", "2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing afterCol is a no-op",
|
||||
recs: [][]string{
|
||||
{"marketing", "b"},
|
||||
{"mk1", "2"},
|
||||
},
|
||||
col: "marketing",
|
||||
afterCol: "model",
|
||||
want: [][]string{
|
||||
{"marketing", "b"},
|
||||
{"mk1", "2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty records is a no-op",
|
||||
recs: [][]string{},
|
||||
col: "marketing",
|
||||
afterCol: "model",
|
||||
want: [][]string{},
|
||||
},
|
||||
{
|
||||
name: "ragged rows are padded without panicking",
|
||||
recs: [][]string{
|
||||
{"a", "model", "b", "marketing"},
|
||||
{"1", "m1"},
|
||||
},
|
||||
col: "marketing",
|
||||
afterCol: "model",
|
||||
want: [][]string{
|
||||
{"a", "model", "marketing", "b"},
|
||||
{"1", "m1", "", ""},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
reorderCSVColumnAfter(c.recs, c.col, c.afterCol)
|
||||
require.Equal(t, c.want, c.recs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10832,19 +10832,21 @@ func (s *integrationTestSuite) TestHostsReportDownload() {
|
||||
res.Body.Close()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, len(hosts)+1) // all hosts + header row
|
||||
assert.Len(t, rows[0], 57) // total number of cols
|
||||
assert.Len(t, rows[0], 58) // total number of cols
|
||||
// Validate that both team_id and fleet_id columns are present.
|
||||
assert.Contains(t, rows[0], "team_id")
|
||||
assert.Contains(t, rows[0], "fleet_id")
|
||||
assert.Contains(t, rows[0], "team_name")
|
||||
assert.Contains(t, rows[0], "fleet_name")
|
||||
|
||||
// hardware_marketing_name is emitted right after hardware_model, shifting
|
||||
// every subsequent column index by one.
|
||||
const (
|
||||
idCol = 3
|
||||
issuesCol = 46
|
||||
gigsDiskCol = 42
|
||||
pctDiskCol = 43
|
||||
gigsTotalCol = 44
|
||||
issuesCol = 47
|
||||
gigsDiskCol = 43
|
||||
pctDiskCol = 44
|
||||
gigsTotalCol = 45
|
||||
)
|
||||
|
||||
// find the row for hosts[1], it should have issues=1 (1 failing policy) and the expected disk space
|
||||
@@ -10880,6 +10882,21 @@ func (s *integrationTestSuite) TestHostsReportDownload() {
|
||||
require.Contains(t, res.Header.Get("Content-Type"), "text/csv")
|
||||
require.Contains(t, res.Header.Get("X-Content-Type-Options"), "nosniff")
|
||||
|
||||
// requesting columns that don't include hardware_model or
|
||||
// hardware_marketing_name returns exactly the requested columns and neither
|
||||
// hardware field (hardware_marketing_name must not leak into the report).
|
||||
res = s.DoRaw(
|
||||
"GET", "/api/latest/fleet/hosts/report", nil, http.StatusOK, "format", "csv",
|
||||
"columns", "hostname,uuid,platform",
|
||||
)
|
||||
rows, err = csv.NewReader(res.Body).ReadAll()
|
||||
res.Body.Close()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, len(hosts)+1)
|
||||
require.Equal(t, []string{"hostname", "uuid", "platform"}, rows[0])
|
||||
require.NotContains(t, rows[0], "hardware_model")
|
||||
require.NotContains(t, rows[0], "hardware_marketing_name")
|
||||
|
||||
// pagination does not apply to this endpoint, it returns the complete list of hosts
|
||||
res = s.DoRaw("GET", "/api/latest/fleet/hosts/report", nil, http.StatusOK, "format", "csv", "page", "1", "per_page", "2", "columns", "hostname")
|
||||
rows, err = csv.NewReader(res.Body).ReadAll()
|
||||
@@ -10982,6 +10999,61 @@ func (s *integrationTestSuite) TestHostsReportDownload() {
|
||||
s.DoRaw("GET", "/api/latest/fleet/hosts/report", nil, http.StatusBadRequest, "software_id", "123", "software_version_id", "456", "software_title_id", "789")
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestHostsReportHardwareMarketingName() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
newHost := func(suffix, platform, model string) *fleet.Host {
|
||||
h, err := s.ds.NewHost(ctx, &fleet.Host{
|
||||
DetailUpdatedAt: time.Now(),
|
||||
LabelUpdatedAt: time.Now(),
|
||||
PolicyUpdatedAt: time.Now(),
|
||||
SeenTime: time.Now(),
|
||||
OsqueryHostID: new(t.Name() + suffix),
|
||||
NodeKey: new(t.Name() + suffix),
|
||||
UUID: uuid.New().String(),
|
||||
Hostname: t.Name() + suffix,
|
||||
Platform: platform,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// hardware_model is not persisted by NewHost, so set it directly.
|
||||
mysqltest.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
|
||||
_, err := db.ExecContext(ctx, `UPDATE hosts SET hardware_model = ? WHERE id = ?`, model, h.ID)
|
||||
return err
|
||||
})
|
||||
return h
|
||||
}
|
||||
|
||||
// Apple host whose model maps to a marketing name, plus a non-Apple host
|
||||
// with no mapping.
|
||||
mapped := newHost("-mapped", "darwin", "MacBookPro18,1")
|
||||
unmapped := newHost("-unmapped", "ubuntu", "Standard PC")
|
||||
|
||||
res := s.DoRaw(
|
||||
"GET", "/api/latest/fleet/hosts/report", nil, http.StatusOK, "format", "csv",
|
||||
"columns", "hostname,hardware_model,hardware_marketing_name",
|
||||
)
|
||||
rows, err := csv.NewReader(res.Body).ReadAll()
|
||||
res.Body.Close()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 3) // header + 2 hosts
|
||||
// columns are returned in the requested order
|
||||
require.Equal(t, []string{"hostname", "hardware_model", "hardware_marketing_name"}, rows[0])
|
||||
|
||||
byHostname := make(map[string][]string, len(rows)-1)
|
||||
for _, row := range rows[1:] {
|
||||
byHostname[row[0]] = row
|
||||
}
|
||||
|
||||
// Apple host: raw model plus the mapped marketing name.
|
||||
require.Equal(t, "MacBookPro18,1", byHostname[mapped.Hostname][1])
|
||||
require.Equal(t, fleet.AppleHardwareModelsToMarketingNames["MacBookPro18,1"], byHostname[mapped.Hostname][2])
|
||||
|
||||
// Non-Apple host: raw model, empty marketing name.
|
||||
require.Equal(t, "Standard PC", byHostname[unmapped.Hostname][1])
|
||||
require.Empty(t, byHostname[unmapped.Hostname][2])
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestSSODisabled() {
|
||||
t := s.T()
|
||||
|
||||
@@ -13229,7 +13301,7 @@ func (s *integrationTestSuite) TestHostsReportWithPolicyResults() {
|
||||
res.Body.Close()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows1, len(hosts)+1) // all hosts + header row
|
||||
assert.Len(t, rows1[0], 57) // total number of cols
|
||||
assert.Len(t, rows1[0], 58) // total number of cols
|
||||
|
||||
var (
|
||||
idIdx int
|
||||
@@ -13259,7 +13331,7 @@ func (s *integrationTestSuite) TestHostsReportWithPolicyResults() {
|
||||
res.Body.Close()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows2, len(hosts)+1) // all hosts + header row
|
||||
assert.Len(t, rows2[0], 57) // total number of cols
|
||||
assert.Len(t, rows2[0], 58) // 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++ {
|
||||
|
||||
@@ -25364,6 +25364,7 @@ func (s *integrationMDMTestSuite) TestErrorOnEnrollmentInstallProfileProducesAct
|
||||
require.NoError(t, apple_mdm.HandleHostMDMProfileInstallResult(ctx, s.ds, host.UUID, case4RenewCmd, &verifying, "", s.fleetSvc.NewActivity))
|
||||
require.Zero(t, countRenewalActivitiesForCmd(case4RenewCmd))
|
||||
}
|
||||
|
||||
func (s *integrationMDMTestSuite) TestInstallAllSelfServiceSoftware() {
|
||||
t := s.T()
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user