diff --git a/changes/43673-my-device-sort-by-display-name b/changes/43673-my-device-sort-by-display-name new file mode 100644 index 0000000000..24141291ea --- /dev/null +++ b/changes/43673-my-device-sort-by-display-name @@ -0,0 +1 @@ +- Fixed "My device" page to sort software by display name instead of installer filename when a custom display name is set. diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 479bafeba0..76cccd0593 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -4683,9 +4683,11 @@ func promoteSoftwareTitleInHouseApp(softwareTitleRecord *hostSoftware) { // hostSoftwareAllowedOrderKeys is minimal: the service layer pins OrderKey to "name". // "source" is included for test determinism (used as the secondary order key in tests). +// "name" uses COALESCE(NULLIF(...)) so that a custom display name (when set) is used +// for sorting, falling back to the software title name (often an installer filename). var hostSoftwareAllowedOrderKeys = common_mysql.OrderKeyAllowlist{ - "name": "name", - "source": "source", + "name": "COALESCE(NULLIF(stdn.display_name, ''), combined_results.name)", + "source": "combined_results.source", } // hostSoftwareTitleAssembler accumulates and de-duplicates host software title records @@ -6522,7 +6524,11 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opt `) } stmt = fmt.Sprintf(stmt, replacements...) - stmt = fmt.Sprintf("SELECT * FROM (%s) AS combined_results", stmt) + stmt = fmt.Sprintf( + "SELECT combined_results.* FROM (%s) AS combined_results LEFT JOIN software_title_display_names stdn ON stdn.software_title_id = combined_results.id AND stdn.team_id = ?", + stmt, + ) + args = append(args, globalOrTeamID) stmt, _, err = appendListOptionsToSQLSecure(stmt, &opts.ListOptions, hostSoftwareAllowedOrderKeys) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "list host software") diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index 144ec85a0b..5496a9dcc5 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -137,6 +137,7 @@ func TestSoftware(t *testing.T) { {"GetSoftwareCategoryNameToIDMap", testGetSoftwareCategoryNameToIDMap}, {"BatchNewSoftwareCategoriesIdempotent", testBatchNewSoftwareCategoriesIdempotent}, {"CreateIntermediateInstallFailureRecordAfterDeletion", testCreateIntermediateInstallFailureRecordAfterDeletion}, + {"ListHostSoftwareSortByDisplayName", testListHostSoftwareSortByDisplayName}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -13027,6 +13028,84 @@ func testGetSoftwareCategoryNameToIDMap(t *testing.T, ds *Datastore) { assert.Empty(t, got) } +func testListHostSoftwareSortByDisplayName(t *testing.T, ds *Datastore) { + ctx := context.Background() + + // Create a team. + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "Display Name Sort Team"}) + require.NoError(t, err) + + // Create a host on the team. + host := test.NewHost(t, ds, "sorthost", "", "sorthostkey", "sorthostuuid", time.Now()) + err = ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&team.ID, []uint{host.ID})) + require.NoError(t, err) + // Reload host to get TeamID set. + host, err = ds.Host(ctx, host.ID) + require.NoError(t, err) + + // Install software on the host. + sw := []fleet.Software{ + {Name: "alpha", Version: "1.0", Source: "apps"}, + {Name: "bravo", Version: "1.0", Source: "apps"}, + {Name: "zzz-installer", Version: "1.0", Source: "apps"}, + } + _, err = ds.UpdateHostSoftware(ctx, host.ID, sw) + require.NoError(t, err) + + require.NoError(t, ds.SyncHostsSoftware(ctx, time.Now())) + require.NoError(t, ds.CleanupSoftwareTitles(ctx)) + require.NoError(t, ds.SyncHostsSoftwareTitles(ctx, time.Now())) + + // Look up the title IDs via ListSoftwareTitles. + adminFilter := fleet.TeamFilter{User: &fleet.User{GlobalRole: new(fleet.RoleAdmin)}} + titles, _, _, err := ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + TeamID: &team.ID, + }, adminFilter) + require.NoError(t, err) + + titleByName := func(name string) uint { + for _, tt := range titles { + if tt.Name == name { + return tt.ID + } + } + t.Fatalf("title %q not found", name) + return 0 + } + + alphaID := titleByName("alpha") + scriptID := titleByName("zzz-installer") + + bravoID := titleByName("bravo") + + // Set display names that reorder the titles: + // alpha -> "Zulu" (should sort last) + // bravo -> "" (empty string, NULLIF falls back to "bravo") + // zzz-installer -> "AAA Script" (should sort first despite filename) + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + if err := updateSoftwareTitleDisplayName(ctx, q, &team.ID, alphaID, "Zulu"); err != nil { + return err + } + // Explicitly set empty display name to exercise the NULLIF(display_name, '') fallback. + if err := updateSoftwareTitleDisplayName(ctx, q, &team.ID, bravoID, ""); err != nil { + return err + } + return updateSoftwareTitleDisplayName(ctx, q, &team.ID, scriptID, "AAA Script") + }) + + // List host software sorted by name ASC. + // Expected order: AAA Script (zzz-installer), bravo, Zulu (alpha). + hostSw, _, err := ds.ListHostSoftware(ctx, host, fleet.HostSoftwareTitleListOptions{ + ListOptions: fleet.ListOptions{OrderKey: "name", OrderDirection: fleet.OrderAscending}, + }) + require.NoError(t, err) + require.Len(t, hostSw, 3) + assert.Equal(t, "zzz-installer", hostSw[0].Name, "AAA Script (zzz-installer) should sort first") + assert.Equal(t, "bravo", hostSw[1].Name, "bravo (no display name) should sort second") + assert.Equal(t, "alpha", hostSw[2].Name, "Zulu (alpha) should sort last") +} + func testBatchNewSoftwareCategoriesIdempotent(t *testing.T, ds *Datastore) { ctx := context.Background()