diff --git a/changes/issue-3320-device-mapping b/changes/issue-3320-device-mapping new file mode 100644 index 0000000000..653302757f --- /dev/null +++ b/changes/issue-3320-device-mapping @@ -0,0 +1,3 @@ +- Added google chrome profile information to hosts page +- Increased MySQL `group_concat_max_len` setting from default 1024 to 4194304 (which corresponds to + `max_allowed_packet` size) diff --git a/cypress/integration/all/app/hosts.spec.ts b/cypress/integration/all/app/hosts.spec.ts index 04e97f6715..7fd6d66651 100644 --- a/cypress/integration/all/app/hosts.spec.ts +++ b/cypress/integration/all/app/hosts.spec.ts @@ -71,6 +71,22 @@ describe("Hosts flow", () => { }); } }); + it(`hides and shows "Used by" column`, () => { + cy.visit("/hosts/manage"); + cy.getAttached("thead").within(() => + cy.findByText(/used by/i).should("not.exist") + ); + cy.getAttached(".table-container").within(() => { + cy.contains("button", /edit columns/i).click(); + }); + cy.getAttached(".edit-columns-modal").within(() => { + cy.findByLabelText(/used by/i).check({ force: true }); + cy.contains("button", /save/i).click(); + }); + cy.getAttached("thead").within(() => + cy.findByText(/used by/i).should("exist") + ); + }); }); describe("Manage policies page", () => { beforeEach(() => { diff --git a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx index 953b1fbd22..c8d665a21d 100644 --- a/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx +++ b/frontend/pages/hosts/ManageHostsPage/HostTableConfig.tsx @@ -2,8 +2,10 @@ // disable this rule as it was throwing an error in Header and Cell component // definitions for the selection row for some reason when we dont really need it. import React from "react"; +import { Column } from "react-table"; +import ReactTooltip from "react-tooltip"; -import { IHost } from "interfaces/host"; +import { IDeviceUser, IHost } from "interfaces/host"; import Checkbox from "components/forms/fields/Checkbox"; import HeaderCell from "components/TableContainer/DataTable/HeaderCell/HeaderCell"; import IssueCell from "components/TableContainer/DataTable/IssueCell/IssueCell"; @@ -18,6 +20,7 @@ import { hostTeamName, } from "utilities/helpers"; import { IConfig } from "interfaces/config"; +import { IDataColumn } from "interfaces/datatable_config"; import { ITeamSummary } from "interfaces/team"; import { IUser } from "interfaces/user"; import PATHS from "router/paths"; @@ -51,16 +54,40 @@ interface ICellProps { }; } -interface IHostDataColumn { - Header: ((props: IHeaderProps) => JSX.Element) | string; - Cell: (props: ICellProps) => JSX.Element; - id?: string; - title?: string; - accessor?: string; - disableHidden?: boolean; - disableSortBy?: boolean; +interface IDeviceUserCellProps { + cell: { + value: IDeviceUser[]; + }; + row: { + original: IHost; + }; } +const condenseDeviceUsers = (users: IDeviceUser[]): string[] => { + if (!users?.length) { + return []; + } + const condensed = + users + .slice(-3) + .map((u) => u.email) + .reverse() || []; + return users.length > 3 + ? condensed.concat(`+${users.length - 3} more`) // TODO: confirm limit + : condensed; +}; + +const tooltipTextWithLineBreaks = (lines: string[]) => { + return lines.map((line) => { + return ( + + {line} +
+
+ ); + }); +}; + const lastSeenTime = (status: string, seenTime: string): string => { if (status !== "online") { return `Last Seen: ${humanHostLastSeen(seenTime)} UTC`; @@ -68,7 +95,7 @@ const lastSeenTime = (status: string, seenTime: string): string => { return "Online"; }; -const allHostTableHeaders: IHostDataColumn[] = [ +const allHostTableHeaders: IDataColumn[] = [ // We are using React Table useRowSelect functionality for the selection header. // More information on its API can be found here // https://react-table.tanstack.com/docs/api/useRowSelect @@ -95,14 +122,14 @@ const allHostTableHeaders: IHostDataColumn[] = [ }, { title: "Hostname", - Header: (cellProps) => ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "hostname", - Cell: (cellProps) => ( + Cell: (cellProps: ICellProps) => ( ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "team_name", - Cell: (cellProps) => ( + Cell: (cellProps: ICellProps) => ( ), }, @@ -132,14 +159,16 @@ const allHostTableHeaders: IHostDataColumn[] = [ Header: "Status", disableSortBy: true, accessor: "status", - Cell: (cellProps) => , + Cell: (cellProps: ICellProps) => ( + + ), }, { title: "Issues", Header: () => host issues, disableSortBy: true, accessor: "issues", - Cell: (cellProps) => ( + Cell: (cellProps: ICellProps) => ( ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "os_version", - Cell: (cellProps) => , + Cell: (cellProps: ICellProps) => , }, { title: "Osquery", - Header: (cellProps) => ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "osquery_version", - Cell: (cellProps) => , + Cell: (cellProps: ICellProps) => , + }, + { + title: "Used by", + Header: "Used by", + disableSortBy: true, + accessor: "device_mapping", + Cell: (cellProps: IDeviceUserCellProps): JSX.Element => { + const numUsers = cellProps.cell.value?.length || 0; + const users = condenseDeviceUsers(cellProps.cell.value || []); + if (users.length) { + const tooltipText = tooltipTextWithLineBreaks(users); + return ( + <> + 1 ? "text-muted" : ""}`} + data-tip + data-for={`device_mapping__${cellProps.row.original.id}`} + data-tip-disable={users.length <= 1} + > + {numUsers === 1 ? users[0] : `${numUsers} users`} + + + {tooltipText} + + + ); + } + return ---; + }, }, { title: "IP address", - Header: (cellProps) => ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "primary_ip", - Cell: (cellProps) => , + Cell: (cellProps: ICellProps) => , }, { title: "Last fetched", - Header: (cellProps) => ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "detail_updated_at", - Cell: (cellProps) => ( + Cell: (cellProps: ICellProps) => ( ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "seen_time", - Cell: (cellProps) => ( + Cell: (cellProps: ICellProps) => ( ), }, { title: "UUID", - Header: (cellProps) => ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "uuid", - Cell: (cellProps) => , + Cell: (cellProps: ICellProps) => , }, { title: "Uptime", - Header: (cellProps) => ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "uptime", - Cell: (cellProps) => ( + Cell: (cellProps: ICellProps) => ( ), }, @@ -237,57 +302,58 @@ const allHostTableHeaders: IHostDataColumn[] = [ Header: "CPU", disableSortBy: true, accessor: "cpu_type", - Cell: (cellProps) => , + Cell: (cellProps: ICellProps) => , }, { title: "RAM", - Header: (cellProps) => ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "memory", - Cell: (cellProps) => ( + Cell: (cellProps: ICellProps) => ( ), }, { title: "MAC address", - Header: (cellProps) => ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "primary_mac", - Cell: (cellProps) => , + Cell: (cellProps: ICellProps) => , }, { title: "Serial number", - Header: (cellProps) => ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "hardware_serial", - Cell: (cellProps) => , + Cell: (cellProps: ICellProps) => , }, { title: "Hardware model", - Header: (cellProps) => ( + Header: (cellProps: IHeaderProps) => ( ), accessor: "hardware_model", - Cell: (cellProps) => , + Cell: (cellProps: ICellProps) => , }, ]; const defaultHiddenColumns = [ + "device_mapping", "primary_mac", "cpu_type", "memory", @@ -306,9 +372,9 @@ const generateAvailableTableHeaders = ( config: IConfig, currentUser: IUser, currentTeam: ITeamSummary | undefined -): IHostDataColumn[] => { +): IDataColumn[] => { return allHostTableHeaders.reduce( - (columns: IHostDataColumn[], currentColumn: IHostDataColumn) => { + (columns: Column[], currentColumn: Column) => { // skip over column headers that are not shown in free observer tier if ( permissionUtils.isFreeTier(config) && @@ -356,7 +422,7 @@ const generateVisibleTableColumns = ( config: IConfig, currentUser: IUser, currentTeam: ITeamSummary | undefined -): IHostDataColumn[] => { +): IDataColumn[] => { // remove columns set as hidden by the user. return generateAvailableTableHeaders(config, currentUser, currentTeam).filter( (column) => { diff --git a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx index dd3a67168a..f5d95773f5 100644 --- a/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx +++ b/frontend/pages/hosts/ManageHostsPage/ManageHostsPage.tsx @@ -478,7 +478,7 @@ const ManageHostsPage = ({ if (options.sortBy) { delete options.sortBy; } - retrieveHostCount(options); + retrieveHostCount(omit(options, "device_mapping")); }; let teamSync = false; @@ -519,6 +519,7 @@ const ManageHostsPage = ({ softwareId, page: tableQueryData ? tableQueryData.pageIndex : 0, perPage: tableQueryData ? tableQueryData.pageSize : 100, + device_mapping: true, }; if (isEqual(options, currentQueryOptions)) { @@ -526,7 +527,7 @@ const ManageHostsPage = ({ } if (teamSync) { retrieveHosts(options); - retrieveHostCount(options); + retrieveHostCount(omit(options, "device_mapping")); setCurrentQueryOptions(options); } }, [availableTeams, currentTeam, location, labels]); diff --git a/frontend/pages/hosts/ManageHostsPage/_styles.scss b/frontend/pages/hosts/ManageHostsPage/_styles.scss index cdb820cf18..8aea4f0fd7 100644 --- a/frontend/pages/hosts/ManageHostsPage/_styles.scss +++ b/frontend/pages/hosts/ManageHostsPage/_styles.scss @@ -127,6 +127,14 @@ } } } + .device_mapping__cell { + .text-cell { + display: inline; + } + .text-muted { + color: $ui-fleet-black-50; + } + } } } } diff --git a/frontend/pages/hosts/details/DeviceUserPage/_styles.scss b/frontend/pages/hosts/details/DeviceUserPage/_styles.scss index faccc83c96..bfb3de9bf8 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/_styles.scss +++ b/frontend/pages/hosts/details/DeviceUserPage/_styles.scss @@ -389,8 +389,8 @@ text-align: center; } - &__device-mapping { - .device-user-tooltip { + &__device_mapping { + .device_mapping--tooltip { flex-direction: column; justify-content: start; text-align: left; diff --git a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss index a6e053aed1..c6ec52c953 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/_styles.scss +++ b/frontend/pages/hosts/details/HostDetailsPage/_styles.scss @@ -404,8 +404,8 @@ margin-right: $pad-small; } - &__device-mapping { - .device-user-tooltip { + &__device_mapping { + .device_mapping--tooltip { flex-direction: column; justify-content: start; text-align: left; diff --git a/frontend/pages/hosts/details/cards/About/About.tsx b/frontend/pages/hosts/details/cards/About/About.tsx index 1313240897..f0fd621a44 100644 --- a/frontend/pages/hosts/details/cards/About/About.tsx +++ b/frontend/pages/hosts/details/cards/About/About.tsx @@ -81,48 +81,44 @@ const About = ({ }; const renderDeviceUser = () => { - const numUsers = deviceMapping?.length; - if (numUsers) { - return ( -
- Used by - - {numUsers === 1 && deviceMapping ? ( - deviceMapping[0].email || "---" - ) : ( - - - {`${numUsers} users`} - - -
- {deviceMapping && - deviceMapping.map((user: any, i: number, arr: any) => ( - {`${user.email}${ - i < arr.length - 1 ? ", " : "" - }`} - ))} -
-
-
- )} -
-
- ); + if (!deviceMapping) { + return null; } - return null; + + const numUsers = deviceMapping.length; + const tooltipText = deviceMapping.map((d) => ( + + {d.email} +
+
+ )); + + return ( +
+ Used by + + {numUsers > 1 ? ( + <> + + {`${numUsers} users`} + + + {tooltipText} + + + ) : ( + deviceMapping[0].email || "---" + )} + +
+ ); }; const renderGeolocation = () => { diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index c40f0024cf..22316d931a 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -18,6 +18,7 @@ export interface ILoadHostsOptions { policyId?: number; policyResponse?: string; softwareId?: number; + device_mapping?: boolean; columns?: string; } @@ -127,6 +128,7 @@ export default { const policyId = options?.policyId || null; const policyResponse = options?.policyResponse || null; const softwareId = options?.softwareId || null; + const device_mapping = options?.device_mapping || null; const columns = options?.columns || null; // TODO: add this query param logic to client class @@ -183,6 +185,10 @@ export default { path += `&software_id=${softwareId}`; } + if (device_mapping) { + path += "&device_mapping=true"; + } + if (columns) { path += `&columns=${columns}`; } diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index 098836aad0..98b4852db8 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -834,7 +834,7 @@ func registerTLS(conf config.MysqlConfig) error { func generateMysqlConnectionString(conf config.MysqlConfig) string { tz := url.QueryEscape("'-00:00'") dsn := fmt.Sprintf( - "%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=true&loc=UTC&time_zone=%s&clientFoundRows=true&allowNativePasswords=true", + "%s:%s@%s(%s)/%s?charset=utf8mb4&parseTime=true&loc=UTC&time_zone=%s&clientFoundRows=true&allowNativePasswords=true&group_concat_max_len=4194304", conf.Username, conf.Password, conf.Protocol, diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index d5ca300dd3..28c1c65dab 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -19,6 +19,7 @@ import ( "testing" "time" + "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" @@ -2189,6 +2190,35 @@ func (s *integrationTestSuite) TestHostDeviceMapping() { require.Len(t, listHosts.Hosts, 0) } +func (s *integrationTestSuite) TestListHostsDeviceMappingSize() { + t := s.T() + ctx := context.Background() + hosts := s.createHosts(t) + + testSize := 50 + var mappings []*fleet.HostDeviceMapping + for i := 0; i < testSize; i++ { + testEmail, _ := server.GenerateRandomText(14) + mappings = append(mappings, &fleet.HostDeviceMapping{HostID: hosts[0].ID, Email: testEmail, Source: "google_chrome_profiles"}) + } + + s.ds.ReplaceHostDeviceMapping(ctx, hosts[0].ID, mappings) + + var listHosts listHostsResponse + s.DoJSON("GET", "/api/latest/fleet/hosts?device_mapping=true", nil, http.StatusOK, &listHosts) + + hostsByID := make(map[uint]HostResponse) + for _, h := range listHosts.Hosts { + hostsByID[h.ID] = h + } + require.NotNil(t, *hostsByID[hosts[0].ID].DeviceMapping) + + var dm []*fleet.HostDeviceMapping + err := json.Unmarshal(*hostsByID[hosts[0].ID].DeviceMapping, &dm) + require.NoError(t, err) + require.Len(t, dm, testSize) +} + func (s *integrationTestSuite) TestGetMacadminsData() { t := s.T()