Feature branch for the Software Self-service story (#19288)

Feature branch for https://github.com/fleetdm/fleet/issues/17587
This commit is contained in:
Martin Angers 2024-06-03 13:47:41 -04:00 committed by GitHub
commit 956edd2839
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
130 changed files with 2433 additions and 766 deletions

View file

@ -0,0 +1 @@
- Updated UI to support software self-service features.

View file

@ -0,0 +1 @@
* Added query parameter `self_service` to filter the list of software titles and the list of a host's software so that only those available to install via self-service are returned.

View file

@ -0,0 +1 @@
* Added the device-authenticated endpoint `POST /device/{token}/software/install/{software_title_id}` to self-install software.

View file

@ -0,0 +1 @@
* Added the `self_service` field to `fleetctl apply` and `fleetctl gitops` YAML configuration files.

View file

@ -0,0 +1 @@
* Added the `self_install` and `software_package` fields to the `installed_software` activity.

View file

@ -0,0 +1 @@
- add UI for the global and host activities for self-service software installation

View file

@ -681,6 +681,7 @@ spec:
- hosts_count: 2
id: 0
name: foo
self_service: false
software_package: null
source: chrome_extensions
versions:
@ -701,6 +702,7 @@ spec:
- hosts_count: 0
id: 0
name: bar
self_service: false
software_package: null
source: deb_packages
versions:
@ -745,6 +747,7 @@ spec:
]
}
],
"self_service": false,
"software_package": null
},
{
@ -760,6 +763,7 @@ spec:
"vulnerabilities": null
}
],
"self_service": false,
"software_package": null
}
]

View file

@ -1028,6 +1028,7 @@ func TestTeamSofwareInstallersGitOps(t *testing.T) {
{"testdata/gitops/team_software_installer_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_post_install_not_found.yml", "no such file or directory"},
{"testdata/gitops/team_software_installer_no_url.yml", "software URL is required"},
{"testdata/gitops/team_software_installer_invalid_self_service_value.yml", "cannot unmarshal string into Go struct field TeamSpecSoftware.self_service of type bool"},
}
for _, c := range cases {
t.Run(filepath.Base(c.file), func(t *testing.T) {

View file

@ -0,0 +1,17 @@
name: "${TEST_TEAM_NAME}"
team_settings:
secrets:
- secret: "ABC"
features:
enable_host_users: true
enable_software_inventory: true
host_expiry_settings:
host_expiry_enabled: true
host_expiry_window: 30
agent_options:
controls:
policies:
queries:
software:
- url: ${SOFTWARE_INSTALLER_URL}/invalidtype.txt
self_service: "not a boolean"

View file

@ -20,3 +20,5 @@ software:
path: lib/query_ruby.yml
post_install_script:
path: lib/post_install_ruby.sh
- url: ${SOFTWARE_INSTALLER_URL}/other.deb
self_service: true

View file

@ -12,7 +12,6 @@ import (
)
func TestVulnerabilityDataStream(t *testing.T) {
t.Skip("TODO: removeme before merging the feature branch")
nettest.Run(t)
runAppCheckErr(t, []string{"vulnerability-data-stream"}, "No directory provided")

View file

@ -1135,7 +1135,9 @@ This activity contains the following fields:
- "host_id": ID of the host.
- "host_display_name": Display name of the host.
- "install_uuid": ID of the software installation.
- "self_service": Whether the installation was initiated by the end user.
- "software_title": Name of the software.
- "software_package": Filename of the installer.
- "status": Status of the software installation.
#### Example
@ -1145,6 +1147,8 @@ This activity contains the following fields:
"host_id": 1,
"host_display_name": "Anna's MacBook Pro",
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"self_service": true,
"install_uuid": "d6cffa75-b5b5-41ef-9230-15073c8a88cf",
"status": "pending"
}
@ -1159,6 +1163,7 @@ This activity contains the following fields:
- "software_package": Filename of the installer.
- "team_name": Name of the team to which this software was added. `null` if it was added to no team." +
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
- "self_service": Whether the software is available for installation by the end user.
#### Example
@ -1167,9 +1172,9 @@ This activity contains the following fields:
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
"team_id": 123
"team_id": 123,
"self_service": true
}
```
## deleted_software
@ -1181,6 +1186,7 @@ This activity contains the following fields:
- "software_package": Filename of the installer.
- "team_name": Name of the team to which this software was added. `null if it was added to no team.
- "team_id": The ID of the team to which this software was added. `null` if it was added to no team.
- "self_service": Whether the software was available for installation by the end user.
#### Example
@ -1189,9 +1195,9 @@ This activity contains the following fields:
"software_title": "Falcon.app",
"software_package": "FalconSensor-6.44.pkg",
"team_name": "Workstations",
"team_id": 123
"team_id": 123,
"self_service": true
}
```

View file

@ -57,7 +57,7 @@ WITH encrypted(enabled) AS (
## disk_space_unix
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, tuxedo
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin
- Query:
```sql
@ -235,7 +235,7 @@ SELECT ipv4 AS address, mac FROM network_interfaces LIMIT 1
## network_interface_unix
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, tuxedo
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin
- Query:
```sql
@ -249,8 +249,8 @@ FROM
-- whereas on Windows ia.interface is the IP of the interface.
JOIN routes r ON r.interface = ia.interface
WHERE
-- Destination 0.0.0.0/0 is the default route on route tables.
r.destination = '0.0.0.0' AND r.netmask = 0
-- Destination 0.0.0.0/0 or ::/0 (IPv6) is the default route on route tables.
(r.destination = '0.0.0.0' OR r.destination = '::') AND r.netmask = 0
-- Type of route is "gateway" for Unix, "remote" for Windows.
AND r.type = 'gateway'
-- We are only interested on private IPs (some devices have their Public IP as Primary IP too).
@ -287,8 +287,8 @@ FROM
-- whereas on Windows ia.interface is the IP of the interface.
JOIN routes r ON r.interface = ia.address
WHERE
-- Destination 0.0.0.0/0 is the default route on route tables.
r.destination = '0.0.0.0' AND r.netmask = 0
-- Destination 0.0.0.0/0 or ::/0 (IPv6) is the default route on route tables.
(r.destination = '0.0.0.0' OR r.destination = '::') AND r.netmask = 0
-- Type of route is "gateway" for Unix, "remote" for Windows.
AND r.type = 'remote'
-- We are only interested on private IPs (some devices have their Public IP as Primary IP too).
@ -311,7 +311,7 @@ LIMIT 1;
## orbit_info
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, windows
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows
- Discovery query:
```sql
@ -345,7 +345,7 @@ SELECT
## os_unix_like
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, tuxedo
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin
- Query:
```sql
@ -384,16 +384,23 @@ WITH display_version_table AS (
SELECT data as display_version
FROM registry
WHERE path = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\DisplayVersion'
),
ubr_table AS (
SELECT data AS ubr
FROM registry
WHERE path ='HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\UBR'
)
SELECT
os.name,
COALESCE(d.display_version, '') AS display_version,
k.version
COALESCE(CONCAT((SELECT version FROM os_version), '.', u.ubr), k.version) AS version
FROM
os_version os,
kernel_info k
LEFT JOIN
display_version_table d
LEFT JOIN
ubr_table u
```
## os_windows
@ -406,24 +413,31 @@ WITH display_version_table AS (
SELECT data as display_version
FROM registry
WHERE path = 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\DisplayVersion'
),
ubr_table AS (
SELECT data AS ubr
FROM registry
WHERE path ='HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\UBR'
)
SELECT
os.name,
os.platform,
os.arch,
k.version as kernel_version,
os.version,
COALESCE(CONCAT((SELECT version FROM os_version), '.', u.ubr), k.version) AS version,
COALESCE(d.display_version, '') AS display_version
FROM
os_version os,
kernel_info k
LEFT JOIN
display_version_table d
LEFT JOIN
ubr_table u
```
## osquery_flags
- Platforms: all
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows
- Query:
```sql
@ -441,7 +455,7 @@ select * from osquery_info limit 1
## scheduled_query_stats
- Platforms: all
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows
- Query:
```sql
@ -662,7 +676,7 @@ FROM homebrew_packages;
## software_vscode_extensions
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, windows, tuxedo
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows
- Discovery query:
```sql
@ -777,7 +791,7 @@ select * from system_info limit 1
## uptime
- Platforms: all
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows
- Query:
```sql
@ -786,7 +800,7 @@ select * from uptime limit 1
## users
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, darwin, windows
- Platforms: linux, ubuntu, debian, rhel, centos, sles, kali, gentoo, amzn, pop, arch, linuxmint, void, nixos, endeavouros, manjaro, opensuse-leap, opensuse-tumbleweed, tuxedo, darwin, windows
- Query:
```sql
@ -821,4 +835,4 @@ SELECT date, title FROM windows_update_history WHERE result_code = 'Succeeded'
<meta name="navSection" value="Dig deeper">
<meta name="pageOrderInSection" value="1600">
<meta name="pageOrderInSection" value="1600">

View file

@ -67,13 +67,13 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.
}
// Create activity
if err := svc.NewActivity(
ctx, vc.User, fleet.ActivityTypeAddedSoftware{
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
TeamName: teamName,
TeamID: payload.TeamID,
}); err != nil {
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
TeamName: teamName,
TeamID: payload.TeamID,
SelfService: payload.SelfService,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for added software")
}
@ -112,13 +112,13 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t
teamName = &t.Name
}
if err := svc.NewActivity(
ctx, vc.User, fleet.ActivityTypeDeletedSoftware{
SoftwareTitle: meta.SoftwareTitle,
SoftwarePackage: meta.Name,
TeamName: teamName,
TeamID: meta.TeamID,
}); err != nil {
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeDeletedSoftware{
SoftwareTitle: meta.SoftwareTitle,
SoftwarePackage: meta.Name,
TeamName: teamName,
TeamID: meta.TeamID,
SelfService: meta.SelfService,
}); err != nil {
return ctxerr.Wrap(ctx, err, "creating activity for deleted software")
}
@ -242,15 +242,8 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
}
ext := filepath.Ext(installer.Name)
var requiredPlatform string
switch ext {
case ".msi", ".exe":
requiredPlatform = "windows"
case ".pkg":
requiredPlatform = "darwin"
case ".deb":
requiredPlatform = "linux"
default:
requiredPlatform := packageExtensionToPlatform(ext)
if requiredPlatform == "" {
// this should never happen
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
}
@ -265,7 +258,7 @@ func (svc *Service) InstallSoftwareTitle(ctx context.Context, hostID uint, softw
}
}
_, err = svc.ds.InsertSoftwareInstallRequest(ctx, hostID, installer.InstallerID)
_, err = svc.ds.InsertSoftwareInstallRequest(ctx, hostID, installer.InstallerID, false)
return ctxerr.Wrap(ctx, err, "inserting software install request")
}
@ -455,6 +448,7 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin
PreInstallQuery: p.PreInstallQuery,
PostInstallScript: p.PostInstallScript,
InstallerFile: bytes.NewReader(bodyBytes),
SelfService: p.SelfService,
}
// set the filename before adding metadata, as it is used as fallback
@ -518,3 +512,68 @@ func (svc *Service) BatchSetSoftwareInstallers(ctx context.Context, tmName strin
return nil
}
func (svc *Service) SelfServiceInstallSoftwareTitle(ctx context.Context, host *fleet.Host, softwareTitleID uint) error {
installer, err := svc.ds.GetSoftwareInstallerMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitleID, false)
if err != nil {
if fleet.IsNotFound(err) {
return &fleet.BadRequestError{
Message: "Software title has no package added. Please add software package to install.",
InternalErr: ctxerr.WrapWithData(
ctx, err, "couldn't find an installer for software title",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
return ctxerr.Wrap(ctx, err, "finding software installer for title")
}
if !installer.SelfService {
return &fleet.BadRequestError{
Message: "Software title is not available through self-service",
InternalErr: ctxerr.NewWithData(
ctx, "software title not available through self-service",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
ext := filepath.Ext(installer.Name)
requiredPlatform := packageExtensionToPlatform(ext)
if requiredPlatform == "" {
// this should never happen
return ctxerr.Errorf(ctx, "software installer has unsupported type %s", ext)
}
if host.FleetPlatform() != requiredPlatform {
return &fleet.BadRequestError{
Message: fmt.Sprintf("Package (%s) can be installed only on %s hosts.", ext, requiredPlatform),
InternalErr: ctxerr.WrapWithData(
ctx, err, "invalid host platform for requested installer",
map[string]any{"host_id": host.ID, "team_id": host.TeamID, "title_id": softwareTitleID},
),
}
}
_, err = svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, installer.InstallerID, true)
return ctxerr.Wrap(ctx, err, "inserting self-service software install request")
}
// packageExtensionToPlatform returns the platform name based on the
// package extension. Returns an empty string if there is no match.
func packageExtensionToPlatform(ext string) string {
var requiredPlatform string
switch ext {
case ".msi", ".exe":
requiredPlatform = "windows"
case ".pkg":
requiredPlatform = "darwin"
case ".deb":
requiredPlatform = "linux"
default:
return ""
}
return requiredPlatform
}

View file

@ -13,6 +13,7 @@ import (
"github.com/fleetdm/fleet/v4/server/authz"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
@ -586,15 +587,36 @@ func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error {
}
func (svc *Service) GetTeam(ctx context.Context, teamID uint) (*fleet.Team, error) {
if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionRead); err != nil {
return nil, err
alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken)
if alreadyAuthd {
// device-authenticated request can only get the device's team
host, ok := hostctx.FromContext(ctx)
if !ok {
err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
return nil, err
}
if host.TeamID == nil || *host.TeamID != teamID {
return nil, authz.ForbiddenWithInternal("device-authenticated host does not belong to requested team", nil, "team", "read")
}
} else {
if err := svc.authz.Authorize(ctx, &fleet.Team{ID: teamID}, fleet.ActionRead); err != nil {
return nil, err
}
}
logging.WithExtras(ctx, "id", teamID)
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
var user *fleet.User
if alreadyAuthd {
// device-authenticated, there is no user in the context, use a global
// observer with no special permissions
user = &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}
} else {
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
user = vc.User
}
team, err := svc.ds.Team(ctx, teamID)
@ -602,7 +624,7 @@ func (svc *Service) GetTeam(ctx context.Context, teamID uint) (*fleet.Team, erro
return nil, err
}
if err = obfuscateSecrets(vc.User, []*fleet.Team{team}); err != nil {
if err = obfuscateSecrets(user, []*fleet.Team{team}); err != nil {
return nil, err
}

View file

@ -139,6 +139,7 @@ const DEFAULT_HOST_SOFTWARE_MOCK: IHostSoftware = {
id: 1,
name: "mock software.app",
package_available_for_install: "mockSoftware.app",
self_service: false,
source: "apps",
bundle_identifier: "com.test.mock",
status: "installed",

View file

@ -1,7 +1,8 @@
import {
ISoftware,
ISoftwareVersion,
ISoftwareTitle,
ISoftwareTitleWithPackageDetail,
ISoftwareTitleWithPackageName,
ISoftwareVulnerability,
ISoftwareTitleVersion,
ISoftwarePackage,
@ -43,7 +44,11 @@ export const createMockSoftwareTitleVersion = (
return { ...DEFAULT_SOFTWARE_TITLE_VERSION_MOCK, ...overrides };
};
const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = {
type MockSoftwareTitle =
| Partial<ISoftwareTitleWithPackageDetail>
| Partial<ISoftwareTitleWithPackageName>;
const DEFAULT_SOFTWARE_TITLE_MOCK = {
id: 1,
name: "mock software 1.app",
software_package: null,
@ -54,16 +59,26 @@ const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = {
versions: [createMockSoftwareTitleVersion()],
};
export const createMockSoftwareTitle = (
overrides?: Partial<ISoftwareTitle>
): ISoftwareTitle => {
return { ...DEFAULT_SOFTWARE_TITLE_MOCK, ...overrides };
export const createMockSoftwareTitle = <
T extends
| Partial<ISoftwareTitleWithPackageDetail>
| Partial<ISoftwareTitleWithPackageName>
>(
overrides: T
) => {
const mock = {
...DEFAULT_SOFTWARE_TITLE_MOCK,
...overrides,
};
return mock;
};
const DEFAULT_SOFTWARE_TITLES_RESPONSE_MOCK: ISoftwareTitlesResponse = {
counts_updated_at: "2020-01-01T00:00:00.000Z",
count: 1,
software_titles: [createMockSoftwareTitle()],
software_titles: [
createMockSoftwareTitle({ software_package: null, self_service: false }),
],
meta: {
has_next_results: false,
has_previous_results: false,
@ -131,13 +146,16 @@ export const createMockSoftwareVersionsReponse = (
};
const DEFAULT_SOFTWARE_TITLE_RESPONSE = {
software_title: createMockSoftwareTitle(),
software_title: createMockSoftwareTitle({
software_package: null,
} as Partial<ISoftwareTitleWithPackageDetail>),
};
export const createMockSoftwareTitleResponse = (
overrides?: Partial<ISoftwareTitleResponse>
overrides: Partial<ISoftwareTitleWithPackageDetail> = {}
): ISoftwareTitleResponse => {
return { ...DEFAULT_SOFTWARE_TITLE_RESPONSE, ...overrides };
const mock = DEFAULT_SOFTWARE_TITLE_RESPONSE.software_title;
return { software_title: { ...mock, ...overrides } };
};
const DEFAULT_SOFTWARE_VERSION_RESPONSE = {
@ -158,6 +176,7 @@ const DEFAULT_SOFTWAREPACKAGE_MOCK: ISoftwarePackage = {
pre_install_query: "SELECT 1 FROM macos_profiles WHERE uuid='abc123';",
post_install_script:
"sudo /Applications/Falcon.app/Contents/Resources/falconctl license abc123",
self_service: false,
status: {
installed: 1,
pending: 2,

View file

@ -3,12 +3,15 @@ import classnames from "classnames";
const baseClass = "card";
type BorderRadiusSize = "small" | "medium" | "large";
type BorderRadiusSize = "small" | "medium" | "large" | "xlarge" | "xxlarge";
type CardColor = "white" | "gray" | "purple" | "yellow";
interface ICardProps {
children?: React.ReactNode;
/** The size of the border radius. Defaults to `small`. */
/** The size of the border radius. Defaults to `small`.
*
* These correspond to the boarder radius in the design system. Look at
* `var/_global.scss` for values */
borderRadiusSize?: BorderRadiusSize;
/** Includes the card shadows. Defaults to `false` */
includeShadow?: boolean;
@ -17,9 +20,11 @@ interface ICardProps {
className?: string;
/** The size of the padding around the content of the card. Defaults to `large`.
*
* These correspond to the padding sizes in the design system. Look at `padding.scss` for values */
* These correspond to the padding sizes in the design system. Look at
* `padding.scss` for values */
paddingSize?: "small" | "medium" | "large" | "xlarge" | "xxlarge";
/** NOTE: DEPRICATED. Use `paddingSize` prop instead.
/**
* @deprecated Use `paddingSize` prop instead.
*
* Increases to 40px padding. Defaults to `false` */
largePadding?: boolean;

View file

@ -5,15 +5,24 @@
padding: $pad-large;
// radius styles
&__radius-small {
border-radius: $border-radius;
}
&__radius-medium {
border-radius: $border-radius-large;
border-radius: $border-radius-medium;
}
&__radius-large {
border-radius: $border-radius-large;
}
&__radius-xlarge {
border-radius: $border-radius-xlarge;
}
&__radius-xxlarge {
border-radius: $border-radius-xxlarge;
}

View file

@ -34,7 +34,7 @@
}
&__border-radius-large {
border-radius: $border-radius-large;
border-radius: $border-radius-medium;
}
&__border-radius-xlarge {

View file

@ -4,15 +4,20 @@ import ReactTooltip from "react-tooltip";
import { uniqueId } from "lodash";
import Icon from "components/Icon";
import { ISoftwarePackage } from "interfaces/software";
import Icon from "components/Icon";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import LinkCell from "../LinkCell";
const baseClass = "software-name-cell";
const InstallIconWithTooltip = () => {
const InstallIconWithTooltip = ({
isSelfService,
}: {
isSelfService: ISoftwarePackage["self_service"];
}) => {
const tooltipId = uniqueId();
return (
<div className={`${baseClass}__install-icon-with-tooltip`}>
@ -21,7 +26,10 @@ const InstallIconWithTooltip = () => {
data-tip
data-for={tooltipId}
>
<Icon name="install" className={`${baseClass}__install-icon`} />
<Icon
name={isSelfService ? "install-self-service" : "install"}
className={`${baseClass}__install-icon`}
/>
</div>
<ReactTooltip
className={`${baseClass}__install-tooltip`}
@ -32,7 +40,14 @@ const InstallIconWithTooltip = () => {
data-html
>
<span className={`${baseClass}__install-tooltip-text`}>
Software can be installed on Host details page.
{isSelfService ? (
<>
End users can install from <b>Fleet Desktop {">"} Self-service</b>
.
</>
) : (
"Software can be installed on Host details page."
)}
</span>
</ReactTooltip>
</div>
@ -45,6 +60,7 @@ interface ISoftwareNameCellProps {
path?: string;
router?: InjectedRouter;
hasPackage?: boolean;
isSelfService?: boolean;
}
const SoftwareNameCell = ({
@ -53,6 +69,7 @@ const SoftwareNameCell = ({
path,
router,
hasPackage = false,
isSelfService = false,
}: ISoftwareNameCellProps) => {
// NO path or router means it's not clickable. return
// a non-clickable cell early
@ -80,7 +97,9 @@ const SoftwareNameCell = ({
<>
<SoftwareIcon name={name} source={source} />
<span className="software-name">{name}</span>
{hasPackage && <InstallIconWithTooltip />}
{hasPackage && (
<InstallIconWithTooltip isSelfService={isSelfService} />
)}
</>
}
/>

View file

@ -21,6 +21,12 @@ interface ITooltipWrapper {
// tipCustomClass?: string;
clickable?: boolean;
tipContent: React.ReactNode;
/** If set to `true`, will not show the tooltip. This can be used to dynamically
* disable the tooltip from the parent component.
*
* @default false
*/
disableTooltip?: boolean;
}
const baseClass = "component__tooltip-wrapper";
@ -37,6 +43,7 @@ const TooltipWrapper = ({
className,
tooltipClass,
clickable = true,
disableTooltip = false,
}: ITooltipWrapper) => {
const wrapperClassNames = classnames(baseClass, className, {
// [`${baseClass}__${wrapperCustomClass}`]: !!wrapperCustomClass,
@ -58,20 +65,22 @@ const TooltipWrapper = ({
<div className={elementClassNames} data-tooltip-id={tipId}>
{children}
</div>
<ReactTooltip5
className={tipClassNames}
id={tipId}
delayShow={isDelayed ? 500 : undefined}
delayHide={isDelayed ? 500 : undefined}
noArrow
place={position}
opacity={1}
disableStyleInjection
clickable={clickable}
offset={5}
>
{tipContent}
</ReactTooltip5>
{!disableTooltip && (
<ReactTooltip5
className={tipClassNames}
id={tipId}
delayShow={isDelayed ? 500 : undefined}
delayHide={isDelayed ? 500 : undefined}
noArrow
place={position}
opacity={1}
disableStyleInjection
clickable={clickable}
offset={5}
>
{tipContent}
</ReactTooltip5>
)}
</span>
);
};

View file

@ -0,0 +1,40 @@
import React from "react";
import { COLORS } from "styles/var/colors";
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
interface IInstallSelfServiceProps {
size?: IconSizes;
color?: keyof typeof COLORS;
}
const InstallSelfService = ({
size = "medium",
color = "ui-fleet-black-50",
}: IInstallSelfServiceProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
viewBox="0 0 16 16"
fill="none"
>
<g clipPath="url(#clip0_386_3648)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58172 12.4183 0 8 0C3.58172 0 0 3.58172 0 8C0 12.4183 3.58172 16 8 16ZM9.00002 4C9.00002 3.44772 8.55231 3 8.00002 3C7.44774 3 7.00003 3.44772 7.00003 4V9.86496L5.64021 8.73178C5.21593 8.37821 4.58537 8.43554 4.2318 8.85982C3.87824 9.28409 3.93556 9.91466 4.35984 10.2682L7.35984 12.7682C7.73069 13.0773 8.26936 13.0773 8.64021 12.7682L11.6402 10.2682C12.0645 9.91466 12.1218 9.28409 11.7682 8.85982C11.4147 8.43554 10.7841 8.37821 10.3598 8.73178L9.00002 9.86496V4Z"
fill={COLORS[color]}
/>
</g>
<defs>
<clipPath id="clip0_386_3648">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
};
export default InstallSelfService;

View file

@ -57,6 +57,7 @@ import Download from "./Download";
import Upload from "./Upload";
import Refresh from "./Refresh";
import Install from "./Install";
import InstallSelfService from "./InstallSelfService";
import Settings from "./Settings";
// a mapping of the usable names of icons to the icon source.
@ -119,6 +120,7 @@ export const ICON_MAP = {
upload: Upload,
refresh: Refresh,
install: Install,
"install-self-service": InstallSelfService,
settings: Settings,
};

View file

@ -77,12 +77,17 @@ export enum ActivityType {
}
// This is a subset of ActivityType that are shown only for the host past activities
export type IHostActivityType =
export type IHostPastActivityType =
| ActivityType.RanScript
| ActivityType.LockedHost
| ActivityType.UnlockedHost
| ActivityType.InstalledSoftware;
// This is a subset of ActivityType that are shown only for the host upcoming activities
export type IHostUpcomingActivityType =
| ActivityType.RanScript
| ActivityType.InstalledSoftware;
export interface IActivity {
created_at: string;
id: number;
@ -94,8 +99,13 @@ export interface IActivity {
details?: IActivityDetails;
}
export type IHostActivity = Omit<IActivity, "type" | "details"> & {
type: IHostActivityType;
export type IHostPastActivity = Omit<IActivity, "type" | "details"> & {
type: IHostPastActivityType;
details: IActivityDetails;
};
export type IHostUpcomingActivity = Omit<IActivity, "type" | "details"> & {
type: IHostUpcomingActivityType;
details: IActivityDetails;
};
@ -142,4 +152,5 @@ export interface IActivityDetails {
software_package?: string;
status?: string;
install_uuid?: string;
self_service?: boolean;
}

View file

@ -235,6 +235,7 @@ export interface IDeviceUserResponse {
host: IHostDevice;
license: ILicense;
org_logo_url: string;
org_contact_url: string;
disk_encryption_enabled?: boolean;
platform?: string;
global_config: IDeviceGlobalConfig;

View file

@ -57,6 +57,7 @@ export interface ISoftwarePackage {
install_script: string;
pre_install_query?: string;
post_install_script?: string;
self_service: boolean;
status: {
installed: number;
pending: number;
@ -64,15 +65,28 @@ export interface ISoftwarePackage {
};
}
export interface ISoftwareTitle {
interface ISoftwareTitle {
id: number;
name: string;
software_package: ISoftwarePackage | null;
software_package: ISoftwarePackage | string | null;
versions_count: number;
source: string;
hosts_count: number;
versions: ISoftwareTitleVersion[] | null;
browser: string;
self_service?: boolean;
}
export interface ISoftwareTitleWithPackageName
extends Omit<ISoftwareTitle, "software_package" | "self-service"> {
software_package: string | null;
self_service: boolean;
}
export interface ISoftwareTitleWithPackageDetail
extends Omit<ISoftwareTitle, "software_package" | "self-service"> {
software_package: ISoftwarePackage | null;
self_service?: never;
}
export interface ISoftwareVulnerability {
@ -210,9 +224,18 @@ export interface IHostSoftware {
id: number;
name: string;
package_available_for_install?: string | null;
self_service: boolean;
source: string;
bundle_identifier?: string;
status: SoftwareInstallStatus | null;
last_install: ISoftwareLastInstall | null;
installed_versions: ISoftwareInstallVersion[] | null;
}
export interface IDeviceSoftware extends IHostSoftware {
package_available_for_install: never;
package: {
name: string;
version: string;
};
}

View file

@ -8,20 +8,6 @@ import { ActivityType } from "interfaces/activity";
import ActivityItem from ".";
const getByTextContent = (text: string) => {
return screen.getByText((content, element) => {
if (!element) {
return false;
}
const hasText = (thisElement: Element) => thisElement.textContent === text;
const elementHasText = hasText(element);
const childrenDontHaveText = Array.from(element?.children || []).every(
(child) => !hasText(child)
);
return elementHasText && childrenDontHaveText;
});
};
describe("Activity Feed", () => {
it("renders avatar, actor name, timestamp", async () => {
const currentDate = new Date();
@ -1178,4 +1164,34 @@ describe("Activity Feed", () => {
expect(screen.getByText("wiped", { exact: false })).toBeInTheDocument();
expect(screen.getByText("Foo Host", { exact: false })).toBeInTheDocument();
});
it("renders the correct actor for a installed_software activity without self_service", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
actor_id: 1,
actor_full_name: "Test Admin",
details: {
software_title: "Foo Software",
host_display_name: "Foo Host",
},
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText("Test Admin")).toBeInTheDocument();
});
it("renders the correct actor for a installed_software activity that was self_service", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
actor_id: 1,
details: {
software_title: "Foo Software",
self_service: true,
host_display_name: "Foo Host",
},
});
render(<ActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText("An end user")).toBeInTheDocument();
});
});

View file

@ -600,7 +600,7 @@ const TAGGED_TEMPLATES = {
);
},
enabledWindowsMdm: (activity: IActivity) => {
enabledWindowsMdm: () => {
return (
<>
{" "}
@ -609,7 +609,7 @@ const TAGGED_TEMPLATES = {
</>
);
},
disabledWindowsMdm: (activity: IActivity) => {
disabledWindowsMdm: () => {
return <> told Fleet to turn off Windows MDM features.</>;
},
// TODO: Combine ranScript template with host details page templates
@ -728,7 +728,7 @@ const TAGGED_TEMPLATES = {
</>
);
},
deletedMultipleSavedQuery: (activity: IActivity) => {
deletedMultipleSavedQuery: () => {
return <> deleted multiple queries.</>;
},
lockedHost: (activity: IActivity) => {
@ -864,8 +864,6 @@ const TAGGED_TEMPLATES = {
return TAGGED_TEMPLATES.defaultActivityTemplate(activity);
}
console.log("onDetailsClick", onDetailsClick);
const {
host_display_name: hostName,
software_title: title,
@ -1014,10 +1012,10 @@ const getDetail = (
return TAGGED_TEMPLATES.transferredHosts(activity);
}
case ActivityType.EnabledWindowsMdm: {
return TAGGED_TEMPLATES.enabledWindowsMdm(activity);
return TAGGED_TEMPLATES.enabledWindowsMdm();
}
case ActivityType.DisabledWindowsMdm: {
return TAGGED_TEMPLATES.disabledWindowsMdm(activity);
return TAGGED_TEMPLATES.disabledWindowsMdm();
}
case ActivityType.RanScript: {
return TAGGED_TEMPLATES.ranScript(activity, onDetailsClick);
@ -1035,7 +1033,7 @@ const getDetail = (
return TAGGED_TEMPLATES.editedWindowsUpdates(activity);
}
case ActivityType.DeletedMultipleSavedQuery: {
return TAGGED_TEMPLATES.deletedMultipleSavedQuery(activity);
return TAGGED_TEMPLATES.deletedMultipleSavedQuery();
}
case ActivityType.LockedHost: {
return TAGGED_TEMPLATES.lockedHost(activity);
@ -1111,18 +1109,30 @@ const ActivityItem = ({
isSandboxMode && PREMIUM_ACTIVITIES.has(activity.type);
const renderActivityPrefix = () => {
if (activity.type === ActivityType.UserLoggedIn) {
return <b>{activity.actor_email} </b>;
const DEFAULT_ACTOR_DISPLAY = <b>{activity.actor_full_name} </b>;
switch (activity.type) {
case ActivityType.UserLoggedIn:
return <b>{activity.actor_email} </b>;
case ActivityType.UserChangedGlobalRole:
case ActivityType.UserChangedTeamRole:
return activity.actor_id === activity.details?.user_id ? (
<b>{activity.details?.user_email} </b>
) : (
DEFAULT_ACTOR_DISPLAY
);
case ActivityType.InstalledSoftware:
return activity.details?.self_service ? (
<span>An end user</span>
) : (
DEFAULT_ACTOR_DISPLAY
);
default:
return DEFAULT_ACTOR_DISPLAY;
}
if (
(activity.type === ActivityType.UserChangedGlobalRole ||
activity.type === ActivityType.UserChangedTeamRole) &&
activity.actor_id === activity.details?.user_id
) {
return <b>{activity.details?.user_email} </b>;
}
return <b>{activity.actor_full_name} </b>;
};
return (
<div className={baseClass}>
<Avatar

View file

@ -4,7 +4,7 @@
border-top: 1px solid #e2e4ea;
border-bottom: 1px solid #e2e4ea;
border-left: 1px solid #e2e4ea;
border-radius: $border-radius-large;
border-radius: $border-radius-medium;
&__loading-spinner {
margin: auto;

View file

@ -8,12 +8,7 @@ const baseClass = "setup-assistant-preview";
const SetupAssistantPreview = () => {
return (
<Card
color="gray"
borderRadiusSize="medium"
paddingSize="xxlarge"
className={baseClass}
>
<Card color="gray" paddingSize="xxlarge" className={baseClass}>
<h2>End user experience</h2>
<p>
After the end user continues past the <b>Remote Management</b> screen,

View file

@ -183,7 +183,7 @@ const SoftwareOSDetailsPage = ({
name={osVersionDetails.platform}
/>
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
className={`${baseClass}__vulnerabilities-section`}
>

View file

@ -29,7 +29,7 @@ import TabsWrapper from "components/TabsWrapper";
import ManageAutomationsModal from "./components/ManageSoftwareAutomationsModal";
import AddSoftwareModal from "./components/AddSoftwareModal";
import { ISoftwareDropdownFilterVal } from "./SoftwareTitles/SoftwareTable/helpers";
import { getSoftwareFilterFromQueryParams } from "./SoftwareTitles/SoftwareTable/helpers";
interface ISoftwareSubNavItem {
name: string;
@ -63,16 +63,6 @@ const getTabIndex = (path: string): number => {
});
};
const getSoftwareFilter = (
vulnerable?: string,
installable?: string
): ISoftwareDropdownFilterVal => {
if (installable === "true") return "installableSoftware";
return vulnerable && vulnerable === "true"
? "vulnerableSoftware"
: "allSoftware";
};
// default values for query params used on this page if not provided
const DEFAULT_SORT_DIRECTION = "desc";
const DEFAULT_SORT_HEADER = "hosts_count";
@ -149,10 +139,11 @@ const SoftwarePage = ({ children, router, location }: ISoftwarePageProps) => {
const query = queryParams && queryParams.query ? queryParams.query : "";
const showExploitedVulnerabilitiesOnly =
queryParams !== undefined && queryParams.exploit === "true";
const softwareFilter = getSoftwareFilter(
queryParams.vulnerable,
queryParams.available_for_install
);
// TODO: there should be better validation of the params depending on the route (e.g., self_service
// and available_for_install don't apply to versions, os, or vulnerabilities routes) and some
// defined redirect behavior if the params are invalid
const softwareFilter = getSoftwareFilterFromQueryParams(queryParams);
const [showManageAutomationsModal, setShowManageAutomationsModal] = useState(
false

View file

@ -1,4 +1,9 @@
import React, { useCallback, useContext, useState } from "react";
import React, {
useCallback,
useContext,
useLayoutEffect,
useState,
} from "react";
import FileSaver from "file-saver";
@ -15,18 +20,58 @@ import { buildQueryStringFromParams } from "utilities/url";
import { internationalTimeFormat } from "utilities/helpers";
import { uploadedFromNow } from "utilities/date_format";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
import Card from "components/Card";
import Graphic from "components/Graphic";
import TooltipWrapper from "components/TooltipWrapper";
import DataSet from "components/DataSet";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import DeleteSoftwareModal from "../DeleteSoftwareModal";
import AdvancedOptionsModal from "../AdvancedOptionsModal";
const baseClass = "software-package-card";
/** TODO: pull this hook and SoftwareName component out. We could use this other places */
function useTruncatedElement(ref: any) {
const [isTruncated, setIsTruncated] = useState(false);
useLayoutEffect(() => {
const element = ref.current;
if (element) {
const { scrollWidth, clientWidth } = element;
setIsTruncated(scrollWidth > clientWidth);
}
}, [ref]);
return isTruncated;
}
interface ISoftwareNameProps {
name: string;
}
const SoftwareName = ({ name }: ISoftwareNameProps) => {
const titleRef = React.useRef<HTMLDivElement>(null);
const isTruncated = useTruncatedElement(titleRef);
return (
<TooltipWrapper
tipContent={name}
position="top"
underline={false}
disableTooltip={!isTruncated}
>
<div ref={titleRef} className={`${baseClass}__title`}>
{name}
</div>
</TooltipWrapper>
);
};
interface IStatusDisplayOption {
displayName: string;
iconName: "success" | "pending-outline" | "error";
@ -96,6 +141,59 @@ const PackageStatusCount = ({
);
};
const DROPDOWN_OPTIONS = [
{
label: "Download",
value: "download",
},
{
label: "Delete",
value: "delete",
},
{
label: "Advanced options",
value: "advanced",
},
] as const;
const ActionsDropdown = ({
onDownloadClick,
onDeleteClick,
onAdvancedOptionsClick,
}: {
onDownloadClick: () => void;
onDeleteClick: () => void;
onAdvancedOptionsClick: () => void;
}) => {
const onSelect = (value: string) => {
switch (value) {
case "download":
onDownloadClick();
break;
case "delete":
onDeleteClick();
break;
case "advanced":
onAdvancedOptionsClick();
break;
default:
// noop
}
};
return (
<div className={`${baseClass}__actions`}>
<Dropdown
className={`${baseClass}__host-actions-dropdown`}
onChange={onSelect}
placeholder="Actions"
searchable={false}
options={DROPDOWN_OPTIONS}
/>
</div>
);
};
interface ISoftwarePackageCardProps {
softwarePackage: ISoftwarePackage;
softwareId: number;
@ -115,7 +213,6 @@ const SoftwarePackageCard = ({
isTeamAdmin,
isTeamMaintainer,
} = useContext(AppContext);
const { renderFlash } = useContext(NotificationContext);
const [showAdvancedOptionsModal, setShowAdvancedOptionsModal] = useState(
@ -171,16 +268,14 @@ const SoftwarePackageCard = ({
isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer;
return (
<Card borderRadiusSize="large" includeShadow className={baseClass}>
<Card borderRadiusSize="xxlarge" includeShadow className={baseClass}>
<div className={`${baseClass}__main-content`}>
{/* TODO: main-info could be a seperate component as its reused on a couple
pages already. Come back and pull this into a component */}
<div className={`${baseClass}__main-info`}>
<Graphic name="file-pkg" />
<div className={`${baseClass}__info`}>
<span className={`${baseClass}__title`}>
{softwarePackage.name}
</span>
<SoftwareName name={softwarePackage.name} />
<span className={`${baseClass}__details`}>
<span>Version {softwarePackage.version} &bull; </span>
<TooltipWrapper
@ -215,20 +310,25 @@ const SoftwarePackageCard = ({
/>
</div>
</div>
{showActions && (
<div className={`${baseClass}__actions`}>
<Button variant="icon" onClick={onAdvancedOptionsClick}>
<Icon name="settings" color={"ui-fleet-black-75"} />
</Button>
{/* TODO: make a component for download icons */}
<Button variant="icon" onClick={onDownloadClick}>
<Icon name="download" color={"ui-fleet-black-75"} />
</Button>
<Button variant="icon" onClick={onDeleteClick}>
<Icon name="trash" color={"ui-fleet-black-75"} />
</Button>
</div>
)}
<div className={`${baseClass}__actions-wrapper`}>
{true && (
<div className={`${baseClass}__self-service-badge`}>
<Icon
name="install-self-service"
size="small"
color="ui-fleet-black-75"
/>
Self-service
</div>
)}
{showActions && (
<ActionsDropdown
onDownloadClick={onDownloadClick}
onDeleteClick={onDeleteClick}
onAdvancedOptionsClick={onAdvancedOptionsClick}
/>
)}
</div>
{showAdvancedOptionsModal && (
<AdvancedOptionsModal
installScript={softwarePackage.install_script}

View file

@ -2,10 +2,11 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: $pad-medium;
&__main-content {
display: flex;
align-items: center;
align-items: flex-start;
gap: $pad-xxlarge;
}
@ -23,6 +24,7 @@
&__title {
font-size: $x-small;
font-weight: $bold;
@include ellipse-text(290px);
}
&__details {
@ -44,12 +46,40 @@
font-weight: normal;
}
&__actions {
&__actions-wrapper {
display: flex;
justify-content: flex-end;
gap: $pad-medium;
}
&__self-service-badge {
display: flex;
height: 18px;
padding: 3px 6px;
align-items: center;
gap: 4px;
border-radius: 4px;
border: 1px solid $ui-fleet-black-10;
background: $ui-off-white;
color: $ui-fleet-black-75;
font-size: $xx-small;
font-weight: $bold;
}
&__actions {
@include button-dropdown;
color: $core-fleet-black;
.Select-multi-value-wrapper {
width: 55px;
}
.Select > .Select-menu-outer {
left: -120px;
}
.Select-placeholder {
color: $core-fleet-black;
}
}
&__download-icon {
display: flex;
justify-content: center;
@ -62,7 +92,7 @@
&__main-content {
display: flex;
flex-direction: column;
align-items: center;
align-items: flex-start;
gap: $pad-large;
}
}

View file

@ -12,7 +12,10 @@ import useTeamIdParam from "hooks/useTeamIdParam";
import { AppContext } from "context/app";
import { ISoftwareTitle, formatSoftwareType } from "interfaces/software";
import {
ISoftwareTitleWithPackageDetail,
formatSoftwareType,
} from "interfaces/software";
import { ignoreAxiosError } from "interfaces/errors";
import softwareAPI, {
ISoftwareTitleResponse,
@ -80,7 +83,7 @@ const SoftwareTitleDetailsPage = ({
} = useQuery<
ISoftwareTitleResponse,
AxiosError,
ISoftwareTitle,
ISoftwareTitleWithPackageDetail,
IGetSoftwareTitleQueryKey[]
>(
[{ scope: "softwareById", softwareId, teamId: teamIdForApi }],
@ -176,7 +179,7 @@ const SoftwareTitleDetailsPage = ({
/>
)}
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
className={`${baseClass}__versions-section`}
>

View file

@ -10,12 +10,19 @@ import { Row } from "react-table";
import PATHS from "router/paths";
import { getNextLocationPath } from "utilities/helpers";
import { GITHUB_NEW_ISSUE_LINK } from "utilities/constants";
import { buildQueryStringFromParams } from "utilities/url";
import {
buildQueryStringFromParams,
convertParamsToSnakeCase,
} from "utilities/url";
import {
ISoftwareApiParams,
ISoftwareTitlesResponse,
ISoftwareVersionsResponse,
} from "services/entities/software";
import { ISoftwareTitle, ISoftwareVersion } from "interfaces/software";
import {
ISoftwareTitleWithPackageName,
ISoftwareVersion,
} from "interfaces/software";
// @ts-ignore
import Dropdown from "components/forms/fields/Dropdown";
@ -33,6 +40,7 @@ import {
ISoftwareDropdownFilterVal,
SOFTWARE_TITLES_DROPDOWN_OPTIONS,
SOFTWARE_VERSIONS_DROPDOWN_OPTIONS,
getSoftwareFilterForQueryKey,
} from "./helpers";
interface IRowProps extends Row {
@ -154,7 +162,10 @@ const SoftwareTable = ({
[determineQueryParamChange, generateNewQueryParams, router, currentPath]
);
let tableData: ISoftwareTitle[] | ISoftwareVersion[] | undefined;
let tableData:
| ISoftwareTitleWithPackageName[]
| ISoftwareVersion[]
| undefined;
let generateTableConfig: ITableConfigGenerator;
if (data === undefined) {
@ -225,28 +236,23 @@ const SoftwareTable = ({
);
};
const handleVulnFilterDropdownChange = (
const handleCustomFilterDropdownChange = (
value: ISoftwareDropdownFilterVal
) => {
const queryParams: Record<string, any> = {
const queryParams: ISoftwareApiParams = {
query,
team_id: teamId,
order_direction: orderDirection,
order_key: orderKey,
teamId,
orderDirection,
orderKey,
page: 0, // resets page index
...getSoftwareFilterForQueryKey(value),
};
if (value === "installableSoftware") {
queryParams.available_for_install = true;
} else {
queryParams.vulnerable = value === "vulnerableSoftware";
}
router.replace(
getNextLocationPath({
pathPrefix: currentPath,
routeTemplate: "",
queryParams,
queryParams: convertParamsToSnakeCase(queryParams),
})
);
};
@ -304,7 +310,7 @@ const SoftwareTable = ({
className={`${baseClass}__vuln_dropdown`}
options={options}
searchable={false}
onChange={handleVulnFilterDropdownChange}
onChange={handleCustomFilterDropdownChange}
tableFilterDropdown
/>
</div>
@ -347,6 +353,9 @@ const SoftwareTable = ({
pageSize={perPage}
showMarkAllPages={false}
isAllPagesSelected={false}
disablePagination={
!data?.meta.has_next_results && !data?.meta.has_previous_results
}
disableNextPage={!data?.meta.has_next_results}
searchable={searchable}
inputPlaceHolder="Search by name or vulnerabilities (CVEs)"

View file

@ -2,7 +2,10 @@ import React from "react";
import { CellProps, Column } from "react-table";
import { InjectedRouter } from "react-router";
import { ISoftwareTitle, formatSoftwareType } from "interfaces/software";
import {
ISoftwareTitleWithPackageName,
formatSoftwareType,
} from "interfaces/software";
import PATHS from "router/paths";
import { buildQueryStringFromParams } from "utilities/url";
@ -19,17 +22,20 @@ import VulnerabilitiesCell from "../../components/VulnerabilitiesCell";
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
type ISoftwareTitlesTableConfig = Column<ISoftwareTitle>;
type ITableStringCellProps = IStringCellProps<ISoftwareTitle>;
type IVersionsCellProps = CellProps<ISoftwareTitle, ISoftwareTitle["versions"]>;
type ISoftwareTitlesTableConfig = Column<ISoftwareTitleWithPackageName>;
type ITableStringCellProps = IStringCellProps<ISoftwareTitleWithPackageName>;
type IVersionsCellProps = CellProps<
ISoftwareTitleWithPackageName,
ISoftwareTitleWithPackageName["versions"]
>;
type IVulnerabilitiesCellProps = IVersionsCellProps;
type IHostCountCellProps = CellProps<
ISoftwareTitle,
ISoftwareTitle["hosts_count"]
ISoftwareTitleWithPackageName,
ISoftwareTitleWithPackageName["hosts_count"]
>;
type IViewAllHostsLinkProps = CellProps<ISoftwareTitle>;
type IViewAllHostsLinkProps = CellProps<ISoftwareTitleWithPackageName>;
type ITableHeaderProps = IHeaderProps<ISoftwareTitle>;
type ITableHeaderProps = IHeaderProps<ISoftwareTitleWithPackageName>;
export const getVulnerabilities = <
T extends { vulnerabilities: string[] | null }
@ -63,7 +69,13 @@ const generateTableHeaders = (
disableSortBy: false,
accessor: "name",
Cell: (cellProps: ITableStringCellProps) => {
const { id, name, source, software_package } = cellProps.row.original;
const {
id,
name,
source,
software_package,
self_service,
} = cellProps.row.original;
const teamQueryParam = buildQueryStringFromParams({ team_id: teamId });
const softwareTitleDetailsPath = `${PATHS.SOFTWARE_TITLE_DETAILS(
@ -79,6 +91,7 @@ const generateTableHeaders = (
path={softwareTitleDetailsPath}
router={router}
hasPackage={hasPackage}
isSelfService={self_service === true}
/>
);
},

View file

@ -1,7 +1,10 @@
import { QueryParams } from "utilities/url";
export type ISoftwareDropdownFilterVal =
| "allSoftware"
| "vulnerableSoftware"
| "installableSoftware";
| "installableSoftware"
| "selfServiceSoftware";
export const SOFTWARE_VERSIONS_DROPDOWN_OPTIONS = [
{
@ -27,4 +30,39 @@ export const SOFTWARE_TITLES_DROPDOWN_OPTIONS = [
value: "installableSoftware",
helpText: "Software that can be installed on your hosts.",
},
{
disabled: false,
label: "Self-service",
value: "selfServiceSoftware",
helpText: "Software that end users can install from Fleet Desktop.",
},
];
export const getSoftwareFilterForQueryKey = (
val: ISoftwareDropdownFilterVal
) => {
switch (val) {
case "installableSoftware":
return { availableForInstall: true };
case "selfServiceSoftware":
return { selfService: true };
case "vulnerableSoftware":
return { vulnerable: true };
default:
return {};
}
};
export const getSoftwareFilterFromQueryParams = (queryParams: QueryParams) => {
const { vulnerable, available_for_install, self_service } = queryParams;
switch (true) {
case available_for_install === "true":
return "installableSoftware";
case self_service === "true":
return "selfServiceSoftware";
case vulnerable === "true":
return "vulnerableSoftware";
default:
return "allSoftware";
}
};

View file

@ -5,11 +5,13 @@
import React from "react";
import { InjectedRouter } from "react-router";
import { useQuery } from "react-query";
import { omit } from "lodash";
import PATHS from "router/paths";
import softwareAPI, {
ISoftwareApiParams,
ISoftwareTitlesQueryKey,
ISoftwareTitlesResponse,
ISoftwareVersionsQueryKey,
ISoftwareVersionsResponse,
} from "services/entities/software";
@ -17,7 +19,10 @@ import Spinner from "components/Spinner";
import TableDataError from "components/DataError";
import SoftwareTable from "./SoftwareTable";
import { ISoftwareDropdownFilterVal } from "./SoftwareTable/helpers";
import {
ISoftwareDropdownFilterVal,
getSoftwareFilterForQueryKey,
} from "./SoftwareTable/helpers";
const baseClass = "software-titles";
@ -27,14 +32,6 @@ const QUERY_OPTIONS = {
staleTime: DATA_STALE_TIME,
};
interface ISoftwareTitlesQueryKey extends ISoftwareApiParams {
scope: "software-titles";
}
interface ISoftwareVersionsQueryKey extends ISoftwareApiParams {
scope: "software-versions";
}
interface ISoftwareTitlesProps {
router: InjectedRouter;
isSoftwareEnabled: boolean;
@ -62,25 +59,6 @@ const SoftwareTitles = ({
}: ISoftwareTitlesProps) => {
const showVersions = location.pathname === PATHS.SOFTWARE_VERSIONS;
const generateSoftwareTitlesQueryKey = (): ISoftwareTitlesQueryKey => {
const queryKey: ISoftwareTitlesQueryKey = {
scope: "software-titles",
page: currentPage,
perPage,
query,
orderDirection,
orderKey,
teamId,
};
if (softwareFilter === "installableSoftware") {
queryKey.availableForInstall = true;
} else {
queryKey.vulnerable = softwareFilter === "vulnerableSoftware";
}
return queryKey;
};
// request to get software data
const {
data: titlesData,
@ -91,10 +69,22 @@ const SoftwareTitles = ({
ISoftwareTitlesResponse,
Error,
ISoftwareTitlesResponse,
ISoftwareTitlesQueryKey[]
[ISoftwareTitlesQueryKey]
>(
[generateSoftwareTitlesQueryKey()],
({ queryKey }) => softwareAPI.getSoftwareTitles(queryKey[0]),
[
{
scope: "software-titles",
page: currentPage,
perPage,
query,
orderDirection,
orderKey,
teamId,
...getSoftwareFilterForQueryKey(softwareFilter),
},
],
({ queryKey: [queryKey] }) =>
softwareAPI.getSoftwareTitles(omit(queryKey, "scope")),
{
...QUERY_OPTIONS,
enabled: location.pathname === PATHS.SOFTWARE_TITLES,
@ -111,7 +101,7 @@ const SoftwareTitles = ({
ISoftwareVersionsResponse,
Error,
ISoftwareVersionsResponse,
ISoftwareVersionsQueryKey[]
[ISoftwareVersionsQueryKey]
>(
[
{
@ -125,7 +115,8 @@ const SoftwareTitles = ({
vulnerable: softwareFilter === "vulnerableSoftware",
},
],
({ queryKey }) => softwareAPI.getSoftwareVersions(queryKey[0]),
({ queryKey: [queryKey] }) =>
softwareAPI.getSoftwareVersions(omit(queryKey, "scope")),
{
...QUERY_OPTIONS,
enabled: location.pathname === PATHS.SOFTWARE_VERSIONS,

View file

@ -152,7 +152,7 @@ const SoftwareVersionDetailsPage = ({
source={softwareVersion.source}
/>
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
className={`${baseClass}__vulnerabilities-section`}
>

View file

@ -68,7 +68,7 @@ const SoftwareVulnOSVersions = ({
};
return (
<Card borderRadiusSize="large" includeShadow className={baseClass}>
<Card borderRadiusSize="xxlarge" includeShadow className={baseClass}>
<h2>Vulnerable OS</h2>
{renderVulnerableOSTable()}
</Card>

View file

@ -68,7 +68,7 @@ const SoftwareVulnSoftwareVersions = ({
);
};
return (
<Card borderRadiusSize="large" includeShadow className={`${baseClass}`}>
<Card borderRadiusSize="xxlarge" includeShadow className={`${baseClass}`}>
<h2>Vulnerable software</h2>
{renderVulnerableSoftwareTable()}
</Card>

View file

@ -38,7 +38,7 @@ const SoftwareVulnSummary = ({
} = vuln;
return (
<Card borderRadiusSize="large" includeShadow className={baseClass}>
<Card borderRadiusSize="xxlarge" includeShadow className={baseClass}>
<span className={`${baseClass}__header`}>
<h1>{cve}</h1>
<span className={`${baseClass}__header__links`}>

View file

@ -1,15 +1,18 @@
import React, { useState } from "react";
import React, { useContext, useState } from "react";
import { NotificationContext } from "context/notification";
import { getFileDetails } from "utilities/file/fileUtils";
import getInstallScript from "utilities/software_install_scripts";
import Spinner from "components/Spinner";
import Button from "components/buttons/Button";
import Checkbox from "components/forms/fields/Checkbox";
import Editor from "components/Editor";
import FileUploader, {
import {
FileUploader,
FileDetails,
} from "components/FileUploader/FileUploader";
import { getFileDetails } from "utilities/file/fileUtils";
import Spinner from "components/Spinner";
import TooltipWrapper from "components/TooltipWrapper";
import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions";
@ -31,6 +34,7 @@ export interface IAddSoftwareFormData {
installScript: string;
preInstallCondition?: string;
postInstallScript?: string;
selfService: boolean;
}
export interface IFormValidation {
@ -38,6 +42,7 @@ export interface IFormValidation {
software: { isValid: boolean };
preInstallCondition?: { isValid: boolean; message?: string };
postInstallScript?: { isValid: boolean; message?: string };
selfService?: { isValid: boolean };
}
interface IAddSoftwareFormProps {
@ -51,6 +56,8 @@ const AddSoftwareForm = ({
onCancel,
onSubmit,
}: IAddSoftwareFormProps) => {
const { renderFlash } = useContext(NotificationContext);
const [showPreInstallCondition, setShowPreInstallCondition] = useState(false);
const [showPostInstallScript, setShowPostInstallScript] = useState(false);
const [formData, setFormData] = useState<IAddSoftwareFormData>({
@ -58,6 +65,7 @@ const AddSoftwareForm = ({
installScript: "",
preInstallCondition: undefined,
postInstallScript: undefined,
selfService: false,
});
const [formValidation, setFormValidation] = useState<IFormValidation>({
isValid: false,
@ -67,10 +75,19 @@ const AddSoftwareForm = ({
const onFileUpload = (files: FileList | null) => {
if (files && files.length > 0) {
const file = files[0];
let installScript: string;
try {
installScript = getInstallScript(file.name);
} catch (e) {
renderFlash("error", `${e}`);
return;
}
const newData = {
...formData,
software: file,
installScript: getInstallScript(file.name),
installScript,
};
setFormData(newData);
setFormValidation(
@ -134,6 +151,18 @@ const AddSoftwareForm = ({
);
};
const onToggleSelfServiceCheckbox = (value: boolean) => {
const newData = { ...formData, selfService: value };
setFormData(newData);
setFormValidation(
generateFormValidation(
newData,
showPreInstallCondition,
showPostInstallScript
)
);
};
const isSubmitDisabled = !formValidation.isValid;
return (
@ -175,6 +204,21 @@ const AddSoftwareForm = ({
}
/>
)}
<Checkbox
value={formData.selfService}
onChange={onToggleSelfServiceCheckbox}
>
<TooltipWrapper
tipContent={
<>
End users can install from{" "}
<b>Fleet Desktop {">"} Self-service</b>.
</>
}
>
Self-service
</TooltipWrapper>
</Checkbox>
<AddSoftwareAdvancedOptions
errors={{
preInstallCondition: formValidation.preInstallCondition?.message,

View file

@ -103,6 +103,10 @@ const FORM_VALIDATION_CONFIG: Record<
},
],
},
selfService: {
// no validations related to self service
validations: [],
},
};
const getErrorMessage = (

View file

@ -2,11 +2,11 @@ import React, { useContext, useEffect, useState } from "react";
import { InjectedRouter } from "react-router";
import PATHS from "router/paths";
import { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import team, { APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import { getErrorReason } from "interfaces/errors";
import softwareAPI from "services/entities/software";
import { NotificationContext } from "context/notification";
import { buildQueryStringFromParams } from "utilities/url";
import { QueryParams, buildQueryStringFromParams } from "utilities/url";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
@ -95,21 +95,29 @@ const AddSoftwareModal = ({
return;
}
// TODO: confirm we are deleting the second sentence (not modifying it) for non-self-service installers
try {
await softwareAPI.addSoftwarePackage(formData, teamId);
renderFlash(
"success",
<>
<b>{formData.software?.name}</b> successfully added. Go to Host
details page to install software.
<b>{formData.software?.name}</b> successfully added.
{formData.selfService
? " The end user can install from Fleet Desktop."
: ""}
</>
);
onExit();
const newQueryParams: QueryParams = { team_id: teamId };
if (formData.selfService) {
newQueryParams.self_service = true;
} else {
newQueryParams.available_for_install = true;
}
router.push(
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams({
available_for_install: true,
team_id: teamId,
})}`
`${PATHS.SOFTWARE_TITLES}?${buildQueryStringFromParams(newQueryParams)}`
);
} catch (e) {
renderFlash("error", getErrorReason(e));

View file

@ -11,7 +11,7 @@ interface IDetailsNoHosts {
const DetailsNoHosts = ({ header, details }: IDetailsNoHosts) => {
return (
<Card borderRadiusSize="large" includeShadow className={baseClass}>
<Card borderRadiusSize="xxlarge" includeShadow className={baseClass}>
<h2>{header}</h2>
<p>{details}</p>
</Card>

View file

@ -10,8 +10,6 @@
.software-icon {
width: 96px;
height: 96px;
border: 1px solid $ui-fleet-black-10;
border-radius: 8px;
}
&__info {

View file

@ -1,3 +1,5 @@
.software-icon {
flex-shrink: 0;
border: 1px solid $ui-fleet-black-10;
border-radius: 8px;
}

View file

@ -73,6 +73,7 @@ export const SOFTWARE_SOURCE_TO_ICON_MAP = {
export const SOFTWARE_ICON_SIZES: Record<string, string> = {
medium: "24",
meduim_large: "64", // TODO: rename this to large and update large to xlarge
large: "96",
} as const;

View file

@ -10,6 +10,8 @@ export const MANAGE_HOSTS_PAGE_FILTER_KEYS = [
"policy_response",
"macos_settings",
"software_id",
"software_version_id",
"software_title_id",
HOSTS_QUERY_PARAMS.SOFTWARE_STATUS,
"status",
"mdm_id",

View file

@ -15,6 +15,7 @@ import {
} from "interfaces/host";
import { IHostPolicy } from "interfaces/policy";
import { IDeviceGlobalConfig } from "interfaces/config";
import DeviceUserError from "components/DeviceUserError";
// @ts-ignore
import OrgLogoIcon from "components/icons/OrgLogoIcon";
@ -45,9 +46,22 @@ import OSSettingsModal from "../OSSettingsModal";
import ResetKeyModal from "./ResetKeyModal";
import BootstrapPackageModal from "../HostDetailsPage/modals/BootstrapPackageModal";
import { parseHostSoftwareQueryParams } from "../cards/Software/HostSoftware";
import SelfService from "../cards/Software/SelfService";
const baseClass = "device-user";
const PREMIUM_TABS = [
PATHS.DEVICE_USER_DETAILS,
PATHS.DEVICE_USER_DETAILS_SELF_SERVICE,
PATHS.DEVICE_USER_DETAILS_SOFTWARE,
PATHS.DEVICE_USER_DETAILS_POLICIES,
] as const;
const FREE_TABS = [
PATHS.DEVICE_USER_DETAILS,
PATHS.DEVICE_USER_DETAILS_SOFTWARE,
] as const;
interface IDeviceUserPageProps {
location: {
pathname: string;
@ -80,6 +94,7 @@ const DeviceUserPage = ({
const [refetchStartTime, setRefetchStartTime] = useState<number | null>(null);
const [showRefetchSpinner, setShowRefetchSpinner] = useState(false);
const [orgLogoURL, setOrgLogoURL] = useState("");
const [orgContactURL, setOrgContactURL] = useState("");
const [selectedPolicy, setSelectedPolicy] = useState<IHostPolicy | null>(
null
);
@ -152,15 +167,19 @@ const DeviceUserPage = ({
refetchOnReconnect: false,
refetchOnWindowFocus: false,
retry: false,
// TODO: refactor to use non-refetch data directly in the component and remove
// unnecesary derived states for values that aren't related to the refetch status
onSuccess: ({
license,
org_logo_url,
org_contact_url,
global_config,
host: responseHost,
}) => {
setShowRefetchSpinner(isRefetching(responseHost));
setIsPremiumTier(license.tier === "premium");
setOrgLogoURL(org_logo_url);
setOrgContactURL(org_contact_url);
setGlobalConfig(global_config);
if (isRefetching(responseHost)) {
// If the API reports that a Fleet refetch request is pending, we want to check back for fresh
@ -324,14 +343,17 @@ const DeviceUserPage = ({
host?.mdm.macos_settings?.disk_encryption === "action_required" &&
host?.mdm.macos_settings?.action_required === "rotate_key";
const tabPaths = [
PATHS.DEVICE_USER_DETAILS(deviceAuthToken),
PATHS.DEVICE_USER_DETAILS_SOFTWARE(deviceAuthToken),
PATHS.DEVICE_USER_DETAILS_POLICIES(deviceAuthToken),
];
// TODO: We should probably have a standard way to handle this on all pages. Do we want to show
// a premium-only message in the case that a user tries direct navigation to a premium-only page
// or silently redirect as below?
const tabPaths = isPremiumTier
? PREMIUM_TABS.map((t) => t(deviceAuthToken))
: FREE_TABS.map((t) => t(deviceAuthToken));
const findSelectedTab = (pathname: string) =>
findIndex(tabPaths, (x) => x.startsWith(pathname.split("?")[0]));
if (!isLoadingHost && host && findSelectedTab(location.pathname) === -1) {
router.push(tabPaths[0]);
}
// TODO: This is a temporary fix that conditionally shows the new software tab depending on
// whether software items returned in the device details response (legacy endpoint).
@ -394,6 +416,7 @@ const DeviceUserPage = ({
>
<TabList>
<Tab>Details</Tab>
{isPremiumTier && <Tab>Self-service</Tab>}
{isSoftwareEnabled && <Tab>Software</Tab>}
{isPremiumTier && (
<Tab>
@ -413,6 +436,18 @@ const DeviceUserPage = ({
munki={deviceMacAdminsData?.munki}
/>
</TabPanel>
{isPremiumTier && (
<TabPanel>
<SelfService
contactUrl={orgContactURL}
deviceToken={deviceAuthToken}
isSoftwareEnabled
pathname={location.pathname}
queryParams={parseHostSoftwareQueryParams(location.query)}
router={router}
/>
</TabPanel>
)}
{isSoftwareEnabled && (
<TabPanel>
<SoftwareCard

View file

@ -13,8 +13,8 @@ import { QueryContext } from "context/query";
import { NotificationContext } from "context/notification";
import activitiesAPI, {
IHostActivitiesResponse,
IUpcomingActivitiesResponse,
IHostPastActivitiesResponse,
IHostUpcomingActivitiesResponse,
} from "services/entities/activities";
import hostAPI from "services/entities/hosts";
import queryAPI from "services/entities/queries";
@ -369,9 +369,9 @@ const HostDetailsPage = ({
isError: pastActivitiesIsError,
refetch: refetchPastActivities,
} = useQuery<
IHostActivitiesResponse,
IHostPastActivitiesResponse,
Error,
IHostActivitiesResponse,
IHostPastActivitiesResponse,
Array<{
scope: string;
pageIndex: number;
@ -407,9 +407,9 @@ const HostDetailsPage = ({
isError: upcomingActivitiesIsError,
refetch: refetchUpcomingActivities,
} = useQuery<
IUpcomingActivitiesResponse,
IHostUpcomingActivitiesResponse,
Error,
IUpcomingActivitiesResponse,
IHostUpcomingActivitiesResponse,
Array<{
scope: string;
pageIndex: number;

View file

@ -187,7 +187,7 @@ const About = ({
return (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
className={baseClass}

View file

@ -3,8 +3,8 @@ import { Tab, TabList, TabPanel, Tabs } from "react-tabs";
import { IActivityDetails } from "interfaces/activity";
import {
IHostActivitiesResponse,
IUpcomingActivitiesResponse,
IHostPastActivitiesResponse,
IHostUpcomingActivitiesResponse,
} from "services/entities/activities";
import Card from "components/Card";
@ -31,8 +31,8 @@ const UpcomingTooltip = () => {
<TooltipWrapper
tipContent={
<>
Upcoming activities will run as listed. Failure of one activity wont
cancel other activities.
Upcoming activities will run as listed. Failure of one activity
won&apos;t cancel other activities.
<br />
<br />
Currently, only scripts are guaranteed to run in order.
@ -47,7 +47,7 @@ const UpcomingTooltip = () => {
interface IActivityProps {
activeTab: "past" | "upcoming";
activities?: IHostActivitiesResponse | IUpcomingActivitiesResponse;
activities?: IHostPastActivitiesResponse | IHostUpcomingActivitiesResponse;
isLoading?: boolean;
isError?: boolean;
upcomingCount: number;
@ -71,7 +71,7 @@ const Activity = ({
// TODO: add count to upcoming activities tab when available via API
return (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
className={baseClass}
@ -100,7 +100,7 @@ const Activity = ({
</TabList>
<TabPanel>
<PastActivityFeed
activities={activities as IHostActivitiesResponse | undefined}
activities={activities as IHostPastActivitiesResponse | undefined}
onDetailsClick={onShowDetails}
isError={isError}
onNextPage={onNextPage}
@ -110,7 +110,9 @@ const Activity = ({
<TabPanel>
<UpcomingTooltip />
<UpcomingActivityFeed
activities={activities as IUpcomingActivitiesResponse | undefined}
activities={
activities as IHostUpcomingActivitiesResponse | undefined
}
onDetailsClick={onShowDetails}
isError={isError}
onNextPage={onNextPage}

View file

@ -2,8 +2,10 @@ import React from "react";
import {
ActivityType,
IHostActivityType,
IHostActivity,
IHostPastActivityType,
IHostPastActivity,
IHostUpcomingActivityType,
IHostUpcomingActivity,
} from "interfaces/activity";
import { ShowActivityDetailsHandler } from "./Activity";
@ -15,7 +17,8 @@ import InstalledSoftwareActivityItem from "./ActivityItems/InstalledSoftwareActi
/** The component props that all host activity items must adhere to */
export interface IHostActivityItemComponentProps {
activity: IHostActivity;
activity: IHostPastActivity | IHostUpcomingActivity;
tab: "past" | "upcoming";
}
/** Used for activity items component that need a show details handler */
@ -25,7 +28,7 @@ export interface IHostActivityItemComponentPropsWithShowDetails
}
export const pastActivityComponentMap: Record<
IHostActivityType,
IHostPastActivityType,
| React.FC<IHostActivityItemComponentProps>
| React.FC<IHostActivityItemComponentPropsWithShowDetails>
> = {
@ -34,3 +37,12 @@ export const pastActivityComponentMap: Record<
[ActivityType.UnlockedHost]: UnlockedHostActivityItem,
[ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem,
};
export const upcomingActivityComponentMap: Record<
IHostUpcomingActivityType,
| React.FC<IHostActivityItemComponentProps>
| React.FC<IHostActivityItemComponentPropsWithShowDetails>
> = {
[ActivityType.RanScript]: RanScriptActivityItem,
[ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem,
};

View file

@ -31,12 +31,17 @@ const InstalledSoftwareActivityItem = ({
onShowDetails,
}: IHostActivityItemComponentPropsWithShowDetails) => {
const { actor_full_name: actorName, details } = activity;
const { self_service, status, software_title: title } = details;
const { status, software_title: title } = details;
const actorDisplayName = self_service ? (
<span>An end user</span>
) : (
<b>{actorName}</b>
);
return (
<HostActivityItem className={baseClass} activity={activity}>
<b>{actorName}</b> {getSoftwareInstallStatusPredicate(status)}{" "}
<>{actorDisplayName}</> {getSoftwareInstallStatusPredicate(status)}{" "}
<b>{title}</b> software on this host.{" "}
<ShowDetailsButton activity={activity} onShowDetails={onShowDetails} />
</HostActivityItem>

View file

@ -9,16 +9,20 @@ import ShowDetailsButton from "../../ShowDetailsButton";
const baseClass = "ran-script-activity-item";
const RanScriptActivityItem = ({
tab,
activity,
onShowDetails,
}: IHostActivityItemComponentPropsWithShowDetails) => {
const ranScriptPrefix = tab === "past" ? "ran" : "told Fleet to run";
return (
<HostActivityItem className={baseClass} activity={activity}>
<b>{activity.actor_full_name}</b>
<>
{" "}
ran {formatScriptNameForActivityItem(activity.details?.script_name)} on
this host.{" "}
{ranScriptPrefix}{" "}
{formatScriptNameForActivityItem(activity.details?.script_name)} on this
host.{" "}
<ShowDetailsButton activity={activity} onShowDetails={onShowDetails} />
</>
</HostActivityItem>

View file

@ -1,7 +1,7 @@
import React from "react";
import { IHostActivity } from "interfaces/activity";
import { IHostActivitiesResponse } from "services/entities/activities";
import { IHostPastActivity } from "interfaces/activity";
import { IHostPastActivitiesResponse } from "services/entities/activities";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
@ -16,7 +16,7 @@ import { pastActivityComponentMap } from "../ActivityConfig";
const baseClass = "past-activity-feed";
interface IPastActivityFeedProps {
activities?: IHostActivitiesResponse;
activities?: IHostPastActivitiesResponse;
isError?: boolean;
onDetailsClick: ShowActivityDetailsHandler;
onNextPage: () => void;
@ -53,11 +53,12 @@ const PastActivityFeed = ({
return (
<div className={baseClass}>
<div>
{activitiesList.map((activity: IHostActivity) => {
{activitiesList.map((activity: IHostPastActivity) => {
const ActivityItemComponent = pastActivityComponentMap[activity.type];
return (
<ActivityItemComponent
key={activity.id}
tab="past"
activity={activity}
onShowDetails={onDetailsClick}
/>

View file

@ -1,116 +0,0 @@
import React from "react";
import ReactTooltip from "react-tooltip";
import { formatDistanceToNowStrict } from "date-fns";
import { ActivityType, IHostActivity } from "interfaces/activity";
import { COLORS } from "styles/var/colors";
import { DEFAULT_GRAVATAR_LINK } from "utilities/constants";
import {
addGravatarUrlToResource,
formatScriptNameForActivityItem,
internationalTimeFormat,
} from "utilities/helpers";
import Avatar from "components/Avatar";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import { ShowActivityDetailsHandler } from "../Activity";
const baseClass = "upcoming-activity";
interface IUpcomingActivityProps {
activity: IHostActivity;
onDetailsClick: ShowActivityDetailsHandler;
}
const formatPredicate = ({ type, details }: IHostActivity) => {
switch (type) {
case ActivityType.RanScript:
return (
<>
told Fleet to run{" "}
{formatScriptNameForActivityItem(details?.script_name)}
</>
);
case ActivityType.InstalledSoftware:
return (
<>
told Fleet to install{" "}
{details?.software_title ? (
<>
<b>{details.software_title}</b>{" "}
</>
) : (
""
)}
software
</>
);
default:
// this should never happen
return <>{type}</>;
}
};
// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and
// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx
const UpcomingActivity = ({
activity,
onDetailsClick,
}: IUpcomingActivityProps) => {
const { actor_email } = activity;
const { gravatar_url } = actor_email
? addGravatarUrlToResource({ email: actor_email })
: { gravatar_url: DEFAULT_GRAVATAR_LINK };
const activityCreatedAt = new Date(activity.created_at);
return (
<div className={baseClass}>
<Avatar
className={`${baseClass}__avatar-image`}
user={{ gravatar_url }}
size="small"
hasWhiteBackground
/>
<div className={`${baseClass}__details-wrapper`}>
<div className="activity-details">
<span className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b> {formatPredicate(activity)} on
this host.{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() => onDetailsClick?.(activity)}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</span>
<br />
<span
className={`${baseClass}__details-bottomline`}
data-tip
data-for={`activity-${activity.id}`}
>
{formatDistanceToNowStrict(activityCreatedAt, {
addSuffix: true,
})}
</span>
<ReactTooltip
className="date-tooltip"
place="top"
type="dark"
effect="solid"
id={`activity-${activity.id}`}
backgroundColor={COLORS["tooltip-bg"]}
>
{internationalTimeFormat(activityCreatedAt)}
</ReactTooltip>
</div>
</div>
<div className={`${baseClass}__dash`} />
</div>
);
};
export default UpcomingActivity;

View file

@ -1,65 +0,0 @@
.upcoming-activity {
display: grid; // Grid system is used to create variable dashed line lengths
grid-template-columns: 16px 16px 1fr;
grid-template-rows: 32px max-content;
.avatar-wrapper {
grid-column-start: 1;
width: 32px;
height: 32px;
}
&__dash {
border-right: 1px dashed $ui-fleet-black-10;
grid-column-start: 1;
grid-row-start: 2;
grid-row-end: 3;
}
&__details-wrapper {
grid-column-start: 3;
grid-row-start: 1;
grid-row-end: 3;
padding-left: $pad-large;
padding-bottom: $pad-large;
.premium-icon-tip {
position: relative;
top: 4px;
padding-right: $pad-xsmall;
}
.activity-details {
margin: 0;
line-height: 16px;
}
}
&__details-topline {
font-size: $x-small;
overflow-wrap: anywhere;
}
&__details-content {
margin-right: $pad-xsmall;
}
&__details-bottomline {
font-size: $xx-small;
color: $ui-fleet-black-25;
}
&__show-query-icon {
margin-left: $pad-xsmall;
}
&:last-child {
.upcoming-activity__dash {
border-right: none;
}
.upcoming-activity__details {
padding-bottom: $pad-xxlarge;
}
}
}

View file

@ -1 +0,0 @@
export { default } from "./UpcomingActivity";

View file

@ -1,7 +1,7 @@
import React from "react";
import { IHostActivity } from "interfaces/activity";
import { IUpcomingActivitiesResponse } from "services/entities/activities";
import { IHostUpcomingActivity } from "interfaces/activity";
import { IHostUpcomingActivitiesResponse } from "services/entities/activities";
// @ts-ignore
import FleetIcon from "components/icons/FleetIcon";
@ -9,13 +9,13 @@ import DataError from "components/DataError";
import Button from "components/buttons/Button";
import EmptyFeed from "../EmptyFeed/EmptyFeed";
import UpcomingActivity from "../UpcomingActivity/UpcomingActivity";
import { ShowActivityDetailsHandler } from "../Activity";
import { upcomingActivityComponentMap } from "../ActivityConfig";
const baseClass = "upcoming-activity-feed";
interface IUpcomingActivityFeedProps {
activities?: IUpcomingActivitiesResponse;
activities?: IHostUpcomingActivitiesResponse;
isError?: boolean;
onDetailsClick: ShowActivityDetailsHandler;
onNextPage: () => void;
@ -52,13 +52,18 @@ const UpcomingActivityFeed = ({
return (
<div className={baseClass}>
<div>
{activitiesList.map((activity: IHostActivity) => (
<UpcomingActivity
key={activity.id}
activity={activity}
onDetailsClick={onDetailsClick}
/>
))}
{activitiesList.map((activity: IHostUpcomingActivity) => {
const ActivityItemComponent =
upcomingActivityComponentMap[activity.type];
return (
<ActivityItemComponent
key={activity.id}
tab="upcoming"
activity={activity}
onShowDetails={onDetailsClick}
/>
);
})}
</div>
<div className={`${baseClass}__pagination`}>
<Button

View file

@ -53,7 +53,7 @@ const AgentOptions = ({
return (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
className={classNames}

View file

@ -384,7 +384,7 @@ const HostSummary = ({
return (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
className={`${baseClass}-card`}

View file

@ -33,7 +33,7 @@ const Labels = ({ onLabelClick, labels }: ILabelsProps): JSX.Element => {
return (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
className={classNames}

View file

@ -27,7 +27,7 @@ const MunkiIssuesTable = ({
return (
<Card
className={`${baseClass} card`}
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
>

View file

@ -72,7 +72,7 @@ const Packs = ({ packsState, isLoading }: IPacksProps): JSX.Element => {
<></>
) : (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
className={baseClass}

View file

@ -98,7 +98,7 @@ const Policies = ({
return (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
className={baseClass}

View file

@ -140,7 +140,7 @@ const HostQueries = ({
return (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
className={baseClass}

View file

@ -287,9 +287,9 @@ const HostSoftware = ({
return (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
paddingSize="xxlarge"
includeShadow
largePadding
className={baseClass}
>
<p className="card__header">Software</p>

View file

@ -143,16 +143,8 @@ export const generateSoftwareTableHeaders = ({
Header: "Install status",
disableSortBy: true,
accessor: "status",
Cell: (cellProps: IInstalledStatusCellProps) => {
const { original } = cellProps.row;
const { value } = cellProps.cell;
return (
<InstallStatusCell
status={value}
packageToInstall={original.package_available_for_install}
installedAt={original.last_install?.installed_at}
/>
);
Cell: ({ row: { original } }: IInstalledStatusCellProps) => {
return <InstallStatusCell {...original} />;
},
},
{

View file

@ -3,7 +3,7 @@ import React, { ReactNode } from "react";
import ReactTooltip from "react-tooltip";
import { uniqueId } from "lodash";
import { SoftwareInstallStatus } from "interfaces/software";
import { IHostSoftware, SoftwareInstallStatus } from "interfaces/software";
import { dateAgo } from "utilities/date_format";
import Icon from "components/Icon";
@ -13,17 +13,28 @@ const baseClass = "install-status-cell";
type IStatusValue = SoftwareInstallStatus | "avaiableForInstall";
type IStatusDisplayConfig = {
iconName: "success" | "pending-outline" | "error" | "install";
export type IStatusDisplayConfig = {
iconName:
| "success"
| "pending-outline"
| "error"
| "install"
| "install-self-service";
displayText: string;
tooltip: (softwareName?: string | null, lastInstall?: string) => ReactNode;
tooltip: (args: {
softwareName?: string | null;
lastInstalledAt?: string;
}) => ReactNode;
};
const CELL_DISPLAY_OPTIONS: Record<IStatusValue, IStatusDisplayConfig> = {
export const INSTALL_STATUS_DISPLAY_OPTIONS: Record<
IStatusValue | "selfService",
IStatusDisplayConfig
> = {
installed: {
iconName: "success",
displayText: "Installed",
tooltip: (_, lastInstall) => (
tooltip: ({ lastInstalledAt: lastInstall }) => (
<>
Fleet installed software on these hosts. (
{dateAgo(lastInstall as string)})
@ -38,7 +49,7 @@ const CELL_DISPLAY_OPTIONS: Record<IStatusValue, IStatusDisplayConfig> = {
failed: {
iconName: "error",
displayText: "Failed",
tooltip: (_, lastInstall) => (
tooltip: ({ lastInstalledAt: lastInstall }) => (
<>
Fleet failed to install software ({dateAgo(lastInstall as string)} ago).
Select <b>Actions &gt; Software details</b> to see more.
@ -48,37 +59,47 @@ const CELL_DISPLAY_OPTIONS: Record<IStatusValue, IStatusDisplayConfig> = {
avaiableForInstall: {
iconName: "install",
displayText: "Available for install",
tooltip: (softwareName) => (
tooltip: ({ softwareName }) => (
<>
<b>{softwareName}</b> can be installed on the host. Select{" "}
<b>Actions &gt; Install</b> to install.
{softwareName ? <b>{softwareName}</b> : "Software"} can be installed on
the host. Select <b>Actions {">"} Install</b> to install.
</>
),
},
selfService: {
iconName: "install-self-service",
displayText: "Self-service",
tooltip: ({ softwareName }) => (
<>
{softwareName ? <b>{softwareName}</b> : "Software"} can be installed on
the host. End users can install from{" "}
<b>Fleet Desktop {">"} Self-service</b>.
</>
),
},
};
interface IInstallStatusCellProps {
status: SoftwareInstallStatus | null;
packageToInstall?: string | null;
installedAt?: string;
}
const InstallStatusCell = ({
status,
packageToInstall,
installedAt,
}: IInstallStatusCellProps) => {
let displayStatus: IStatusValue;
last_install,
package_available_for_install: softwareName,
self_service,
}: IHostSoftware) => {
const lastInstalledAt = last_install?.installed_at;
let displayStatus: keyof typeof INSTALL_STATUS_DISPLAY_OPTIONS;
if (status !== null) {
displayStatus = status;
} else if (packageToInstall) {
} else if (softwareName && self_service) {
displayStatus = "selfService";
} else if (softwareName) {
displayStatus = "avaiableForInstall";
} else {
return <TextCell value="---" greyed />;
}
const displayConfig = CELL_DISPLAY_OPTIONS[displayStatus];
const displayConfig = INSTALL_STATUS_DISPLAY_OPTIONS[displayStatus];
const tooltipId = uniqueId();
return (
@ -88,7 +109,8 @@ const InstallStatusCell = ({
data-tip
data-for={tooltipId}
>
<Icon name={displayConfig.iconName} />
<Icon name={displayConfig.iconName} />{" "}
<span>{displayConfig.displayText}</span>
</div>
<ReactTooltip
className={`${baseClass}__status-tooltip`}
@ -98,10 +120,12 @@ const InstallStatusCell = ({
data-html
>
<span className={`${baseClass}__status-tooltip-text`}>
{displayConfig.tooltip(packageToInstall, installedAt)}
{displayConfig.tooltip({
softwareName,
lastInstalledAt,
})}
</span>
</ReactTooltip>
<span>{displayConfig.displayText}</span>
</div>
);
};

View file

@ -5,6 +5,11 @@
gap: $pad-small;
}
&__status-with-tooltip {
display: flex;
gap: $pad-small;
}
&__status-tooltip {
text-align: center;
}

View file

@ -0,0 +1,152 @@
import React, { useCallback } from "react";
import { useQuery } from "react-query";
import { InjectedRouter } from "react-router";
import { AxiosError } from "axios";
import deviceApi, {
IDeviceSoftwareQueryKey,
IGetDeviceSoftwareResponse,
} from "services/entities/device_user";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import Card from "components/Card";
import CustomLink from "components/CustomLink";
import DataError from "components/DataError";
import EmptyTable from "components/EmptyTable";
import Spinner from "components/Spinner";
import Pagination from "pages/ManageControlsPage/components/Pagination";
import { parseHostSoftwareQueryParams } from "../HostSoftware";
import SelfServiceItem from "./SelfServiceItem";
const baseClass = "software-self-service";
// These default params are not subject to change by the user
const DEFAULT_SELF_SERVICE_QUERY_PARAMS = {
per_page: 9,
order_key: "name",
order_direction: "asc",
query: "",
self_service: true,
} as const;
const SoftwareSelfService = ({
contactUrl,
deviceToken,
isSoftwareEnabled,
pathname,
queryParams,
router,
}: {
contactUrl: string; // TODO: confirm this has been added to the device API response
deviceToken: string;
isSoftwareEnabled?: boolean;
pathname: string;
queryParams: ReturnType<typeof parseHostSoftwareQueryParams>;
router: InjectedRouter;
}) => {
// TOOD: loading state for fetching?
const { data, isLoading, isError, refetch } = useQuery<
IGetDeviceSoftwareResponse,
AxiosError,
IGetDeviceSoftwareResponse,
IDeviceSoftwareQueryKey[]
>(
[
{
scope: "device_software",
id: deviceToken,
page: queryParams.page,
...DEFAULT_SELF_SERVICE_QUERY_PARAMS,
},
],
({ queryKey }) => deviceApi.getDeviceSoftware(queryKey[0]),
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: isSoftwareEnabled,
keepPreviousData: true,
staleTime: 7000,
}
);
const onNextPage = useCallback(() => {
router.push(pathname.concat(`?page=${queryParams.page + 1}`));
}, [pathname, queryParams.page, router]);
const onPrevPage = useCallback(() => {
router.push(pathname.concat(`?page=${queryParams.page - 1}`));
}, [pathname, queryParams.page, router]);
// TODO: handle empty state better, this is just a placeholder for now
// TODO: what should happen if query params are invalid (e.g., page is negative or exceeds the
// available results)?
const isEmpty = !data?.software.length && !data?.meta.has_previous_results;
return (
<Card
borderRadiusSize="xxlarge"
includeShadow
paddingSize="xxlarge"
className={baseClass}
>
<div className={`${baseClass}__card-header`}>Self-service</div>
<div className={`${baseClass}__card-subheader`}>
Install organization-approved apps provided by your IT department.{" "}
{contactUrl && (
<span>
If you need help,{" "}
<CustomLink url={contactUrl} text="reach out to IT" newTab />
</span>
)}
</div>
{isLoading ? (
<Spinner />
) : (
<>
{isError && <DataError />}
{!isError && (
<div className={baseClass}>
{isEmpty ? (
<EmptyTable
graphicName="empty-software"
header="No self-service software available yet"
info="Your organization didn't add any self-service software. If you need any, reach out to your IT department."
/>
) : (
<>
<div className={`${baseClass}__items-count`}>
<b>{data.count} items</b>
</div>
<div className={`${baseClass}__items`}>
{data.software.map((s) => {
const key = `${s.id}${s.last_install?.install_uuid}`; // concatenating install_uuid so item updates with fresh data on refetch
return (
<SelfServiceItem
key={key}
deviceToken={deviceToken}
software={s}
onInstall={refetch}
/>
);
})}
</div>
<Pagination
disableNext={data.meta.has_next_results === false}
disablePrev={data.meta.has_previous_results === false}
onNextPage={onNextPage}
onPrevPage={onPrevPage}
className={`${baseClass}__pagination`}
/>
</>
)}
</div>
)}
</>
)}
</Card>
);
};
export default SoftwareSelfService;

View file

@ -0,0 +1,221 @@
import React, { useCallback, useContext, useEffect, useRef } from "react";
import ReactTooltip from "react-tooltip";
import {
IDeviceSoftware,
IHostSoftware,
SoftwareInstallStatus,
} from "interfaces/software";
import deviceApi from "services/entities/device_user";
import { dateAgo } from "utilities/date_format";
import { NotificationContext } from "context/notification";
import Card from "components/Card";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import { IStatusDisplayConfig } from "../../InstallStatusCell/InstallStatusCell";
const baseClass = "self-service-item";
const STATUS_CONFIG: Record<SoftwareInstallStatus, IStatusDisplayConfig> = {
installed: {
iconName: "success",
displayText: "Installed",
tooltip: ({ lastInstalledAt }) => (
<>
Software installed successfully ({dateAgo(lastInstalledAt as string)}).
</>
),
},
pending: {
iconName: "pending-outline",
displayText: "Install in progress...",
tooltip: () => "Software installation in progress...",
},
failed: {
iconName: "error",
displayText: "Failed",
tooltip: ({ lastInstalledAt = "" }) => (
<>
Software failed to install
{lastInstalledAt ? `(${dateAgo(lastInstalledAt)})` : ""}. Select{" "}
<b>Retry</b> to install again, or contact your IT department.
</>
),
},
};
interface IInstallerInfoProps {
software: IDeviceSoftware;
}
const InstallerInfo = ({ software }: IInstallerInfoProps) => {
const { name, source, package: installerPackage } = software;
return (
<div className={`${baseClass}__item-topline`}>
<div className={`${baseClass}__item-icon`}>
<SoftwareIcon name={name} source={source} size="medium_large" />
</div>
<div className={`${baseClass}__item-name-version`}>
<div className={`${baseClass}__item-name`}>
{name || installerPackage?.name}
</div>
<div className={`${baseClass}__item-version`}>
{installerPackage?.version || ""}
</div>
</div>
</div>
);
};
type IInstallerStatusProps = Pick<
IHostSoftware,
"id" | "status" | "last_install"
>;
const InstallerStatus = ({
id,
status,
last_install,
}: IInstallerStatusProps) => {
const displayConfig = STATUS_CONFIG[status as keyof typeof STATUS_CONFIG];
if (!displayConfig) {
// API should ensure this never happens, but just in case
return null;
}
return (
<div className={`${baseClass}__status-content`}>
<div
className={`${baseClass}__status-with-tooltip`}
data-tip
data-for={`install-tooltip__${id}`}
>
<Icon name={displayConfig.iconName} />
<span>{displayConfig.displayText}</span>
</div>
<ReactTooltip
className={`${baseClass}__status-tooltip`}
effect="solid"
backgroundColor="#3e4771"
id={`install-tooltip__${id}`}
data-html
>
<span className={`${baseClass}__status-tooltip-text`}>
{displayConfig.tooltip({
lastInstalledAt: last_install?.installed_at,
})}
</span>
</ReactTooltip>
</div>
);
};
interface IInstallerStatusActionProps {
deviceToken: string;
software: IHostSoftware;
onInstall: () => void;
}
const InstallerStatusAction = ({
deviceToken,
software: { id, status, last_install },
onInstall,
}: IInstallerStatusActionProps) => {
const { renderFlash } = useContext(NotificationContext);
// localStatus is used to track the status of the any user-initiated install action
const [localStatus, setLocalStatus] = React.useState<
SoftwareInstallStatus | undefined
>(undefined);
// displayStatus allows us to display the localStatus (if any) or the status from the list
// software reponse
const displayStatus = localStatus || status;
// if the localStatus is "failed", we don't our tooltip to include the old installed_at date so we
// set this to null, which tells the tooltip to omit the parenthetical date
const lastInstall = localStatus === "failed" ? null : last_install;
const isMountedRef = useRef(false);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const onClick = useCallback(async () => {
setLocalStatus("pending");
try {
await deviceApi.installSelfServiceSoftware(deviceToken, id);
if (isMountedRef.current) {
onInstall();
}
} catch (error) {
renderFlash("error", "Couldn't install. Please try again.");
if (isMountedRef.current) {
setLocalStatus("failed");
}
}
}, [deviceToken, id, onInstall, renderFlash]);
return (
<div className={`${baseClass}__item-status-action`}>
<div className={`${baseClass}__item-status`}>
<InstallerStatus
id={id}
status={displayStatus}
last_install={lastInstall}
/>
</div>
<div className={`${baseClass}__item-action`}>
{(displayStatus === "failed" || displayStatus === null) && (
<Button
variant="text-icon"
type="button"
className={`${baseClass}__item-action-button${
localStatus === "pending" ? "--installing" : ""
}`}
onClick={onClick}
>
{displayStatus === "failed" ? "Retry" : "Install"}
</Button>
)}
</div>
</div>
);
};
interface ISelfServiceItemProps {
deviceToken: string;
software: IDeviceSoftware;
onInstall: () => void;
}
const SelfServiceItem = ({
deviceToken,
software,
onInstall,
}: ISelfServiceItemProps) => {
return (
<Card
borderRadiusSize="large"
paddingSize="medium"
className={`${baseClass}__item`}
>
<div className={`${baseClass}__item-content`}>
<InstallerInfo software={software} />
<InstallerStatusAction
deviceToken={deviceToken}
software={software}
onInstall={onInstall}
/>
</div>
</Card>
);
};
export default SelfServiceItem;

View file

@ -0,0 +1,97 @@
.self-service-item {
&__item {
display: flex;
flex-direction: column;
}
&__item-content {
display: flex;
flex-direction: column;
gap: 8px;
}
&__item-topline {
display: flex;
flex-direction: row;
height: 64px;
align-items: center;
gap: 16px;
overflow: hidden;
}
&__item-icon {
display: flex;
height: 64px;
min-width: 64px;
}
&__item-name-version {
display: flex;
flex-direction: column;
justify-content: center;
height: 64px;
overflow: hidden;
}
&__item-name {
font-size: $x-small;
font-weight: $bold;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
&__item-version {
font-size: $xx-small;
color: $ui-fleet-black-75;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
&__item-status-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 16px;
border-top: 1px solid $ui-fleet-black-10;
}
&__item-status {
display: flex;
align-items: center;
gap: 8px;
}
&__item-action {
display: flex;
align-items: center;
gap: 8px;
}
&__status-content {
display: flex;
align-items: center;
gap: $pad-small;
}
&__status-with-tooltip {
display: flex;
flex-direction: row;
align-items: center;
gap: $pad-small;
span {
font-size: $x-small;
}
}
&__item-action-button {
height: auto;
&--installing {
display: none;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./SelfServiceItem";

View file

@ -0,0 +1,35 @@
.software-self-service {
&__card-header {
margin: 0 0 8px 0;
}
&__card-subheader {
margin: 0 0 24px 0;
color: $ui-fleet-black-75;
font-size: $x-small;
}
// TODO: empty table styling differs slightly from figma (font size, color, spacing), why?g
.empty-table__container {
margin: 64px 0;
}
&__items-count {
margin: 0 0 24px 0;
font-size: $x-small;
font-weight: $bold;
}
&__items {
display: grid;
gap: $pad-large;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
}
&__pagination {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
}

View file

@ -0,0 +1 @@
export { default } from "./SelfService";

View file

@ -31,7 +31,7 @@ const Users = ({
if (!hostUsersEnabled) {
return (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
className={baseClass}
@ -56,7 +56,7 @@ const Users = ({
return (
<Card
borderRadiusSize="large"
borderRadiusSize="xxlarge"
includeShadow
largePadding
className={baseClass}

View file

@ -63,7 +63,7 @@
background-color: $core-white;
border: none;
box-shadow: inset 0 0 0 1px $ui-fleet-black-25;
border-radius: $border-radius-large;
border-radius: $border-radius-medium;
cursor: pointer;
display: flex;
align-items: center;

View file

@ -293,6 +293,7 @@ const routes = (
<Route component={DeviceUserPage}>
<Route path=":device_auth_token" component={DeviceUserPage}>
<Route path="self-service" component={DeviceUserPage} />
<Route path="software" component={DeviceUserPage} />
<Route path="policies" component={DeviceUserPage} />
</Route>

View file

@ -137,6 +137,9 @@ export default {
DEVICE_USER_DETAILS: (deviceAuthToken: string): string => {
return `${URL_PREFIX}/device/${deviceAuthToken}`;
},
DEVICE_USER_DETAILS_SELF_SERVICE: (deviceAuthToken: string): string => {
return `${URL_PREFIX}/device/${deviceAuthToken}/self-service`;
},
DEVICE_USER_DETAILS_SOFTWARE: (deviceAuthToken: string): string => {
return `${URL_PREFIX}/device/${deviceAuthToken}/software`;
},

View file

@ -1,5 +1,9 @@
import endpoints from "utilities/endpoints";
import { IActivity, IHostActivity } from "interfaces/activity";
import {
IActivity,
IHostPastActivity,
IHostUpcomingActivity,
} from "interfaces/activity";
import sendRequest from "services";
import { buildQueryStringFromParams } from "utilities/url";
@ -16,16 +20,21 @@ export interface IActivitiesResponse {
};
}
export interface IHostActivitiesResponse {
activities: IHostActivity[] | null;
export interface IHostPastActivitiesResponse {
activities: IHostPastActivity[] | null;
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export interface IUpcomingActivitiesResponse extends IHostActivitiesResponse {
export interface IHostUpcomingActivitiesResponse {
count: number;
activities: IHostUpcomingActivity[] | null;
meta: {
has_next_results: boolean;
has_previous_results: boolean;
};
}
export default {
@ -53,7 +62,7 @@ export default {
id: number,
page = DEFAULT_PAGE,
perPage = DEFAULT_PAGE_SIZE
): Promise<IHostActivitiesResponse> => {
): Promise<IHostPastActivitiesResponse> => {
const { HOST_PAST_ACTIVITIES } = endpoints;
const queryParams = {
@ -72,7 +81,7 @@ export default {
id: number,
page = DEFAULT_PAGE,
perPage = DEFAULT_PAGE_SIZE
): Promise<IUpcomingActivitiesResponse> => {
): Promise<IHostUpcomingActivitiesResponse> => {
const { HOST_UPCOMING_ACTIVITIES } = endpoints;
const queryParams = {

View file

@ -1,5 +1,5 @@
import { IDeviceUserResponse } from "interfaces/host";
import { IHostSoftware } from "interfaces/software";
import { IDeviceSoftware } from "interfaces/software";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import { buildQueryStringFromParams } from "utilities/url";
@ -13,7 +13,7 @@ export interface IDeviceSoftwareQueryKey extends IHostSoftwareQueryParams {
}
export interface IGetDeviceSoftwareResponse {
software: IHostSoftware[];
software: IDeviceSoftware[];
count: number;
meta: {
has_next_results: boolean;
@ -53,4 +53,14 @@ export default {
const queryString = buildQueryStringFromParams(rest);
return sendRequest("GET", `${DEVICE_SOFTWARE(id)}?${queryString}`);
},
installSelfServiceSoftware: (
deviceToken: string,
softwareTitleId: number
) => {
const { DEVICE_SOFTWARE_INSTALL } = endpoints;
const path = DEVICE_SOFTWARE_INSTALL(deviceToken, softwareTitleId);
return sendRequest("POST", path);
},
};

View file

@ -12,7 +12,6 @@ import {
import { SelectedPlatform } from "interfaces/platform";
import {
IHostSoftware,
ISoftwareTitle,
ISoftware,
SoftwareInstallStatus,
} from "interfaces/software";
@ -34,7 +33,7 @@ export interface ISortOption {
export interface ILoadHostsResponse {
hosts: IHost[];
software: ISoftware | undefined;
software_title: ISoftwareTitle | undefined;
software_title: { name: string; version?: string } | null | undefined; // TODO: confirm type
munki_issue: IMunkiIssuesAggregate;
mobile_device_management_solution: IMdmSolution;
}

View file

@ -1,16 +1,18 @@
import { AxiosResponse } from "axios";
import { snakeCase, reduce } from "lodash";
import sendRequest from "services";
import endpoints from "utilities/endpoints";
import {
ISoftwareResponse,
ISoftwareCountResponse,
ISoftwareVersion,
ISoftwareTitle,
ISoftwareTitleWithPackageDetail,
ISoftwareTitleWithPackageName,
} from "interfaces/software";
import { buildQueryStringFromParams, QueryParams } from "utilities/url";
import {
buildQueryStringFromParams,
convertParamsToSnakeCase,
} from "utilities/url";
import { IAddSoftwareFormData } from "pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm";
@ -22,13 +24,14 @@ export interface ISoftwareApiParams {
query?: string;
vulnerable?: boolean;
availableForInstall?: boolean;
selfService?: boolean;
teamId?: number;
}
export interface ISoftwareTitlesResponse {
counts_updated_at: string | null;
count: number;
software_titles: ISoftwareTitle[];
software_titles: ISoftwareTitleWithPackageName[];
meta: {
has_next_results: boolean;
has_previous_results: boolean;
@ -46,13 +49,21 @@ export interface ISoftwareVersionsResponse {
}
export interface ISoftwareTitleResponse {
software_title: ISoftwareTitle;
software_title: ISoftwareTitleWithPackageDetail;
}
export interface ISoftwareVersionResponse {
software: ISoftwareVersion;
}
export interface ISoftwareVersionsQueryKey extends ISoftwareApiParams {
scope: "software-versions";
}
export interface ISoftwareTitlesQueryKey extends ISoftwareApiParams {
scope: "software-titles";
}
export interface ISoftwareQueryKey extends ISoftwareApiParams {
scope: "software";
}
@ -85,17 +96,6 @@ export interface IGetSoftwareVersionQueryKey
const ORDER_KEY = "name";
const ORDER_DIRECTION = "asc";
const convertParamsToSnakeCase = (params: ISoftwareApiParams) => {
return reduce<typeof params, QueryParams>(
params,
(result, val, key) => {
result[snakeCase(key)] = val;
return result;
},
{}
);
};
export default {
load: async ({
page,
@ -104,9 +104,12 @@ export default {
orderDirection: orderDir = ORDER_DIRECTION,
query,
vulnerable,
availableForInstall,
// availableForInstall, // TODO: Is this supported for the versions endpoint?
teamId,
}: ISoftwareApiParams): Promise<ISoftwareResponse> => {
}: Omit<
ISoftwareApiParams,
"availableForInstall" | "selfService"
>): Promise<ISoftwareResponse> => {
const { SOFTWARE } = endpoints;
const queryParams = {
page,
@ -116,7 +119,7 @@ export default {
teamId,
query,
vulnerable,
availableForInstall,
// availableForInstall,
};
const snakeCaseParams = convertParamsToSnakeCase(queryParams);
@ -197,6 +200,7 @@ export default {
const formData = new FormData();
formData.append("software", data.software);
formData.append("self_service", data.selfService.toString());
data.installScript && formData.append("install_script", data.installScript);
data.preInstallCondition &&
formData.append("pre_install_query", data.preInstallCondition);

View file

@ -1,6 +1,7 @@
// border radius
$border-radius: 4px;
$border-radius-large: 6px;
$border-radius-medium: 6px;
$border-radius-large: 8px;
$border-radius-xlarge: 10px;
$border-radius-xxlarge: 16px;

View file

@ -182,10 +182,11 @@ $max-width: 2560px;
}
}
@mixin ellipse-text {
@mixin ellipse-text($width: auto) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: $width;
}
@mixin copy-message {

View file

@ -1,10 +1,17 @@
import { formatDistanceToNow } from "date-fns";
// eslint-disable-next-line import/prefer-default-export
/** Utility to create a string from a date in this format:
`Uploaded .... ago`
*/
export const uploadedFromNow = (date: string) => {
// NOTE: Malformed dates will result in errors. This is expected "fail loudly" behavior.
return `Uploaded ${formatDistanceToNow(new Date(date))} ago`;
};
/** Utility to create a string from a date in this format:
`.... ago`
*/
export const dateAgo = (date: string) => {
// NOTE: Malformed dates will result in errors. This is expected "fail loudly" behavior.
return `${formatDistanceToNow(new Date(date))} ago`;
};

View file

@ -31,6 +31,8 @@ export default {
DEVICE_USER_DETAILS: `/${API_VERSION}/fleet/device`,
DEVICE_SOFTWARE: (token: string) =>
`/${API_VERSION}/fleet/device/${token}/software`,
DEVICE_SOFTWARE_INSTALL: (token: string, softwareTitleId: number) =>
`/${API_VERSION}/fleet/device/${token}/software/install/${softwareTitleId}`,
DEVICE_USER_RESET_ENCRYPTION_KEY: (token: string): string => {
return `/${API_VERSION}/fleet/device/${token}/rotate_encryption_key`;
},

View file

@ -24,7 +24,7 @@ import {
} from "date-fns";
import yaml from "js-yaml";
import { buildQueryStringFromParams } from "utilities/url";
import { QueryParams, buildQueryStringFromParams } from "utilities/url";
import { IHost } from "interfaces/host";
import { ILabel } from "interfaces/label";
import { IPack } from "interfaces/pack";
@ -828,7 +828,7 @@ interface ILocationParams {
pathPrefix?: string;
routeTemplate?: string;
routeParams?: { [key: string]: string };
queryParams?: { [key: string]: string | number | undefined };
queryParams?: QueryParams;
}
type RouteParams = Record<string, string>;

View file

@ -1,4 +1,4 @@
import { isEmpty, reduce, omitBy, Dictionary } from "lodash";
import { isEmpty, reduce, omitBy, Dictionary, snakeCase } from "lodash";
import {
DiskEncryptionStatus,
@ -250,3 +250,22 @@ export const getLabelParam = (selectedLabels?: string[]) => {
return label.slice(7);
};
type QueryParamish<T> = keyof T extends string
? {
[K in keyof T]: QueryValues;
}
: never;
export const convertParamsToSnakeCase = <T extends QueryParamish<T>>(
params: T
) => {
return reduce<typeof params, QueryParams>(
params,
(result, val, key) => {
result[snakeCase(key)] = val;
return result;
},
{}
);
};

View file

@ -0,0 +1 @@
* Added the `Self-service` menu item to Fleet Desktop.

View file

@ -130,6 +130,10 @@ func main() {
transparencyItem := systray.AddMenuItem("Transparency", "")
transparencyItem.Disable()
systray.AddSeparator()
selfServiceItem := systray.AddMenuItem("Self-service", "")
selfServiceItem.Disable()
tokenReader := token.Reader{Path: identifierPath}
if _, err := tokenReader.Read(); err != nil {
@ -175,6 +179,7 @@ func main() {
myDeviceItem.SetTitle("Connecting...")
myDeviceItem.Disable()
transparencyItem.Disable()
selfServiceItem.Disable()
migrateMDMItem.Disable()
migrateMDMItem.Hide()
}
@ -198,6 +203,7 @@ func main() {
myDeviceItem.SetTitle("My device")
myDeviceItem.Enable()
transparencyItem.Enable()
selfServiceItem.Enable()
return
}
@ -390,6 +396,11 @@ func main() {
if err := open.Browser(openURL); err != nil {
log.Error().Err(err).Str("url", openURL).Msg("open browser transparency")
}
case <-selfServiceItem.ClickedCh:
openURL := client.BrowserSelfServiceURL(tokenReader.GetCached())
if err := open.Browser(openURL); err != nil {
log.Error().Err(err).Str("url", openURL).Msg("open browser self-service")
}
case <-migrateMDMItem.ClickedCh:
if err := mdmMigrator.Show(); err != nil {
go reportError(err, nil)

View file

@ -296,8 +296,10 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
'host_id', hsi.host_id,
'host_display_name', COALESCE(hdn.display_name, ''),
'software_title', COALESCE(st.name, ''),
'software_package', si.filename,
'install_uuid', hsi.execution_id,
'status', %s
'status', CAST(%s AS CHAR),
'self_service', si.self_service IS TRUE
) as details
FROM
host_software_installs hsi
@ -333,9 +335,9 @@ func (ds *Datastore) ListHostUpcomingActivities(ctx context.Context, hostID uint
"ran_script_type": fleet.ActivityTypeRanScript{}.ActivityName(),
"installed_software_type": fleet.ActivityTypeInstalledSoftware{}.ActivityName(),
"max_wait_time": seconds,
"software_status_failed": fleet.SoftwareInstallerFailed,
"software_status_installed": fleet.SoftwareInstallerInstalled,
"software_status_pending": fleet.SoftwareInstallerPending,
"software_status_failed": string(fleet.SoftwareInstallerFailed),
"software_status_installed": string(fleet.SoftwareInstallerInstalled),
"software_status_pending": string(fleet.SoftwareInstallerPending),
})
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "build list query from named args")

View file

@ -431,9 +431,9 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
require.NoError(t, err)
h1E := hsr.ExecutionID
// create some software installs requests for h1, make some complete
h1FooFailed, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID)
h1FooFailed, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID, false)
require.NoError(t, err)
h1Bar, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw2Meta.InstallerID)
h1Bar, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw2Meta.InstallerID, false)
require.NoError(t, err)
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: h1.ID,
@ -441,7 +441,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
PreInstallConditionOutput: ptr.String(""), // pre-install failed
})
require.NoError(t, err)
h1FooInstalled, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID)
h1FooInstalled, err := ds.InsertSoftwareInstallRequest(ctx, h1.ID, sw1Meta.InstallerID, false)
require.NoError(t, err)
err = ds.SetHostSoftwareInstallResult(ctx, &fleet.HostSoftwareInstallResultPayload{
HostID: h1.ID,
@ -450,7 +450,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
InstallScriptExitCode: ptr.Int(0),
})
require.NoError(t, err)
h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID) // no user for this one
h1Foo, err := ds.InsertSoftwareInstallRequest(noUserCtx, h1.ID, sw1Meta.InstallerID, false) // no user for this one
require.NoError(t, err)
// create a single pending request for h2, as well as a non-pending one
@ -463,7 +463,7 @@ func testListHostUpcomingActivities(t *testing.T, ds *Datastore) {
require.NoError(t, err)
h2F := hsr.ExecutionID
// add a pending software install request for h2
h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID)
h2Bar, err := ds.InsertSoftwareInstallRequest(ctx, h2.ID, sw2Meta.InstallerID, false)
require.NoError(t, err)
// nothing for h3

View file

@ -0,0 +1,28 @@
package tables
import (
"database/sql"
"fmt"
)
func init() {
MigrationClient.AddMigration(Up_20240521143024, Down_20240521143024)
}
func Up_20240521143024(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE software_installers ADD COLUMN self_service bool NOT NULL DEFAULT false`)
if err != nil {
return fmt.Errorf("failed to add self_service to software_installers: %w", err)
}
_, err = tx.Exec(`ALTER TABLE host_software_installs ADD COLUMN self_service bool NOT NULL DEFAULT false`)
if err != nil {
return fmt.Errorf("failed to add self_service bool to host_software_installs: %w", err)
}
return nil
}
func Down_20240521143024(tx *sql.Tx) error {
return nil
}

Some files were not shown because too many files have changed in this diff Show more