From b55e2980c513008de6365ad60908916b4069a2c4 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Tue, 21 May 2024 10:13:12 -0400 Subject: [PATCH 01/16] Add self_service bool to software_installers table (#19122) #19116 --- .../20240517135955_SoftwareSelfServiceBool.go | 28 +++++++++ ...0517135955_SoftwareSelfServiceBool_test.go | 63 +++++++++++++++++++ server/datastore/mysql/schema.sql | 6 +- 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 server/datastore/mysql/migrations/tables/20240517135955_SoftwareSelfServiceBool.go create mode 100644 server/datastore/mysql/migrations/tables/20240517135955_SoftwareSelfServiceBool_test.go diff --git a/server/datastore/mysql/migrations/tables/20240517135955_SoftwareSelfServiceBool.go b/server/datastore/mysql/migrations/tables/20240517135955_SoftwareSelfServiceBool.go new file mode 100644 index 0000000000..2fa1ccfcec --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240517135955_SoftwareSelfServiceBool.go @@ -0,0 +1,28 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240517135955, Down_20240517135955) +} + +func Up_20240517135955(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_20240517135955(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240517135955_SoftwareSelfServiceBool_test.go b/server/datastore/mysql/migrations/tables/20240517135955_SoftwareSelfServiceBool_test.go new file mode 100644 index 0000000000..91fbe9e217 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240517135955_SoftwareSelfServiceBool_test.go @@ -0,0 +1,63 @@ +package tables + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUp_20240517135955(t *testing.T) { + db := applyUpToPrev(t) + + // + // Insert data to test the migration + // + // ... + + script1 := execNoErrLastID(t, db, "INSERT INTO script_contents(contents, md5_checksum) VALUES ('echo hi', 'a')") + script2 := execNoErrLastID(t, db, "INSERT INTO script_contents(contents, md5_checksum) VALUES ('echo bye', 'b')") + + software := execNoErrLastID(t, db, ` +INSERT INTO software_installers ( + filename, + version, + platform, + install_script_content_id, + post_install_script_content_id, + storage_id +) VALUES ( + 'fleet', + '1.0.0', + 'windows', + ?, + ?, + 'a' +)`, script1, script2) + + host := insertHost(t, db, nil) + + install := execNoErrLastID(t, db, ` +INSERT INTO host_software_installs ( + host_id, + execution_id, + software_installer_id +) VALUES (?, ?, ?)`, host, "e", software) + + // Apply current migration. + applyNext(t, db) + + // + // Check data, insert new entries, e.g. to verify migration is safe. + // + // ... + + var self_service bool + err := db.Get(&self_service, "SELECT self_service FROM software_installers WHERE id = ?", software) + require.NoError(t, err) + require.False(t, self_service) + + var host_self_service bool + err = db.Get(&host_self_service, "SELECT self_service FROM host_software_installs WHERE id = ?", install) + require.NoError(t, err) + require.False(t, host_self_service) +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 791888aa10..246dc1e300 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -499,6 +499,7 @@ CREATE TABLE `host_software_installs` ( `user_id` int(10) unsigned DEFAULT NULL, `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `self_service` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_host_software_installs_execution_id` (`execution_id`), KEY `fk_host_software_installs_installer_id` (`software_installer_id`), @@ -910,9 +911,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=266 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=267 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240517135955,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( @@ -1513,6 +1514,7 @@ CREATE TABLE `software_installers` ( `post_install_script_content_id` int(10) unsigned DEFAULT NULL, `storage_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, `uploaded_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `self_service` tinyint(1) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `idx_software_installers_team_id_title_id` (`global_or_team_id`,`title_id`), KEY `fk_software_installers_title` (`title_id`), From 87c4deb30772407690dca6af2bd99919307cc3c2 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Thu, 23 May 2024 09:47:04 -0400 Subject: [PATCH 02/16] Software SS: Update APIs with `self_service` (#19187) --- ee/server/service/software_installers.go | 3 +++ server/datastore/mysql/software_installers.go | 10 +++++++--- server/fleet/activities.go | 2 ++ server/fleet/scripts.go | 1 + server/fleet/software_installer.go | 7 +++++++ server/service/integration_enterprise_test.go | 20 +++++++++++-------- server/service/software_installers.go | 11 ++++++++++ 7 files changed, 43 insertions(+), 11 deletions(-) diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index bb8e51234c..5e72681862 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -72,6 +72,7 @@ func (svc *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet. SoftwarePackage: payload.Filename, TeamName: teamName, TeamID: payload.TeamID, + SelfService: payload.SelfService, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for added software") } @@ -116,6 +117,7 @@ func (svc *Service) DeleteSoftwareInstaller(ctx context.Context, titleID uint, t SoftwarePackage: meta.Name, TeamName: teamName, TeamID: meta.TeamID, + SelfService: meta.SelfService, }); err != nil { return ctxerr.Wrap(ctx, err, "creating activity for deleted software") } @@ -453,6 +455,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 diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index 8eef7a7374..3cc7d36d2b 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -104,8 +104,9 @@ INSERT INTO software_installers ( install_script_content_id, pre_install_query, post_install_script_content_id, - platform -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + platform, + self_service +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` args := []interface{}{ payload.TeamID, @@ -118,6 +119,7 @@ INSERT INTO software_installers ( payload.PreInstallQuery, postInstallScriptID, payload.Platform, + payload.SelfService, } res, err := ds.writer(ctx).ExecContext(ctx, stmt, args...) @@ -204,6 +206,7 @@ SELECT si.pre_install_query, si.post_install_script_content_id, si.uploaded_at, + si.self_service, COALESCE(st.name, '') AS software_title %s FROM @@ -299,7 +302,8 @@ SELECT h.team_id AS host_team_id, hsi.user_id AS user_id, hsi.post_install_script_exit_code, - hsi.install_script_exit_code + hsi.install_script_exit_code, + hsi.self_service FROM host_software_installs hsi JOIN hosts h ON h.id = hsi.host_id diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 5646802e46..4b05ad1951 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -1455,6 +1455,7 @@ type ActivityTypeAddedSoftware struct { SoftwarePackage string `json:"software_package"` TeamName *string `json:"team_name"` TeamID *uint `json:"team_id"` + SelfService bool `json:"self_service"` } func (a ActivityTypeAddedSoftware) ActivityName() string { @@ -1477,6 +1478,7 @@ func (a ActivityTypeAddedSoftware) Documentation() (string, string, string) { } type ActivityTypeDeletedSoftware struct { + SelfService bool `json:"self_service"` SoftwareTitle string `json:"software_title"` SoftwarePackage string `json:"software_package"` TeamName *string `json:"team_name"` diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index 70ccb510d3..6f803f28c1 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -363,6 +363,7 @@ type SoftwareInstallerPayload struct { PreInstallQuery string `json:"pre_install_query"` InstallScript string `json:"install_script"` PostInstallScript string `json:"post_install_script"` + SelfService bool `json:"self_service"` } type HostLockWipeStatus struct { diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 068e539fc6..6f6073e2fd 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -95,6 +95,9 @@ type SoftwareInstaller struct { Status *SoftwareInstallerStatusSummary `json:"status,omitempty" db:"-"` // SoftwareTitle is the title of the software pointed installed by this installer. SoftwareTitle string `json:"-" db:"software_title"` + // SelfService indicates that the software can be installed by the + // end user without admin intervention + SelfService bool `json:"-" db:"self_service"` } // AuthzType implements authz.AuthzTyper. @@ -175,6 +178,9 @@ type HostSoftwareInstallerResult struct { InstallScriptExitCode *int `json:"-" db:"install_script_exit_code"` // PostInstallScriptExitCode is used internally to determine the output displayed to the user. PostInstallScriptExitCode *int `json:"-" db:"post_install_script_exit_code"` + // SelfService indicates that the installation was queued by the + // end user and not an administrator + SelfService bool `json:"self_service" db:"self_service"` } const ( @@ -252,6 +258,7 @@ type UploadSoftwareInstallerPayload struct { Version string Source string Platform string + SelfService bool } // DownloadSoftwareInstallerPayload is the payload for downloading a software installer. diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index e5a7aca8b7..4f1d1a112b 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -8970,7 +8970,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.uploadSoftwareInstaller(payload, http.StatusOK, "") // check activity - s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), `{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null}`, 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), `{"software_title": "ruby", "software_package": "ruby.deb", "team_name": null, "team_id": null, "self_service": false}`, 0) // check the software installer _, titleID := checkSoftwareInstaller(t, payload) @@ -9005,11 +9005,12 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD PostInstallScript: "another post install script", Filename: "ruby.deb", // additional fields below are pre-populated so we can re-use the payload later for the test assertions - Title: "ruby", - Version: "1:2.5.1", - Source: "deb_packages", - StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", - Platform: "linux", + Title: "ruby", + Version: "1:2.5.1", + Source: "deb_packages", + StorageID: "df06d9ce9e2090d9cb2e8cd1f4d7754a803dc452bf93e3204e3acd3b95508628", + Platform: "linux", + SelfService: true, } s.uploadSoftwareInstaller(payload, http.StatusOK, "") @@ -9017,7 +9018,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD installerID, titleID := checkSoftwareInstaller(t, payload) // check activity - s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeAddedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d, "self_service": true}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) // upload again fails s.uploadSoftwareInstaller(payload, http.StatusConflict, "already exists") @@ -9057,7 +9058,7 @@ func (s *integrationEnterpriseTestSuite) TestSoftwareInstallerUploadDownloadAndD s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/software/%d/package", titleID), nil, http.StatusNoContent, "team_id", fmt.Sprintf("%d", *payload.TeamID)) // check activity - s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) + s.lastActivityOfTypeMatches(fleet.ActivityTypeDeletedSoftware{}.ActivityName(), fmt.Sprintf(`{"software_title": "ruby", "software_package": "ruby.deb", "team_name": "%s", "team_id": %d, "self_service": true}`, createTeamResp.Team.Name, createTeamResp.Team.ID), 0) }) } @@ -9714,6 +9715,9 @@ func (s *integrationEnterpriseTestSuite) uploadSoftwareInstaller(payload *fleet. require.NoError(t, w.WriteField("install_script", payload.InstallScript)) require.NoError(t, w.WriteField("pre_install_query", payload.PreInstallQuery)) require.NoError(t, w.WriteField("post_install_script", payload.PostInstallScript)) + if payload.SelfService { + require.NoError(t, w.WriteField("self_service", "true")) + } w.Close() diff --git a/server/service/software_installers.go b/server/service/software_installers.go index 9a51b7f5f4..b56473c13b 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -20,6 +20,7 @@ type uploadSoftwareInstallerRequest struct { InstallScript string PreInstallQuery string PostInstallScript string + SelfService bool } type uploadSoftwareInstallerResponse struct { @@ -79,6 +80,15 @@ func (uploadSoftwareInstallerRequest) DecodeRequest(ctx context.Context, r *http decoded.PostInstallScript = val[0] } + val, ok = r.MultipartForm.Value["self_service"] + if ok && len(val) > 0 && val[0] != "" { + parsed, err := strconv.ParseBool(val[0]) + if err != nil { + return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode self_service bool in multipart form: %s", err.Error())} + } + decoded.SelfService = parsed + } + return &decoded, nil } @@ -99,6 +109,7 @@ func uploadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, s PostInstallScript: req.PostInstallScript, InstallerFile: ff, Filename: req.File.Filename, + SelfService: req.SelfService, } if err := svc.UploadSoftwareInstaller(ctx, payload); err != nil { From 6c639270fb7a92ddcbaa8fd4a902f6bd5a5b46df Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 27 May 2024 10:31:16 -0400 Subject: [PATCH 03/16] Software SS: add CLI support for `self_service` (#19205) --- changes/18834-fleetctl-add-self-service-field | 1 + cmd/fleetctl/gitops_test.go | 1 + ...are_installer_invalid_self_service_value.yml | 17 +++++++++++++++++ .../gitops/team_software_installer_valid.yml | 2 ++ cmd/fleetctl/vulnerability_data_stream_test.go | 2 ++ server/fleet/teams.go | 1 + server/service/client.go | 1 + server/service/integration_core_test.go | 2 +- server/service/integration_enterprise_test.go | 15 ++++++++++++++- server/service/orbit_client_test.go | 12 +++++------- 10 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 changes/18834-fleetctl-add-self-service-field create mode 100644 cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml diff --git a/changes/18834-fleetctl-add-self-service-field b/changes/18834-fleetctl-add-self-service-field new file mode 100644 index 0000000000..4a934e3493 --- /dev/null +++ b/changes/18834-fleetctl-add-self-service-field @@ -0,0 +1 @@ +* Added the `self_service` field to `fleetctl apply` and `fleetctl gitops` YAML configuration files. diff --git a/cmd/fleetctl/gitops_test.go b/cmd/fleetctl/gitops_test.go index 36a354a994..371f135cbe 100644 --- a/cmd/fleetctl/gitops_test.go +++ b/cmd/fleetctl/gitops_test.go @@ -966,6 +966,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) { diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml new file mode 100644 index 0000000000..b27a982703 --- /dev/null +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_invalid_self_service_value.yml @@ -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" diff --git a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml index 42ec2fc59c..ceeb1a7415 100644 --- a/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml +++ b/cmd/fleetctl/testdata/gitops/team_software_installer_valid.yml @@ -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 diff --git a/cmd/fleetctl/vulnerability_data_stream_test.go b/cmd/fleetctl/vulnerability_data_stream_test.go index 0e61949eea..4693db8d71 100644 --- a/cmd/fleetctl/vulnerability_data_stream_test.go +++ b/cmd/fleetctl/vulnerability_data_stream_test.go @@ -12,6 +12,8 @@ import ( ) func TestVulnerabilityDataStream(t *testing.T) { + t.Skip("TODO: re-enable before merging feature branch to main!") + nettest.Run(t) runAppCheckErr(t, []string{"vulnerability-data-stream"}, "No directory provided") diff --git a/server/fleet/teams.go b/server/fleet/teams.go index d7c41f1d8b..984d295343 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -163,6 +163,7 @@ type TeamSpecSoftwareAsset struct { type TeamSpecSoftware struct { URL string `json:"url"` + SelfService bool `json:"self_service"` PreInstallQuery TeamSpecSoftwareAsset `json:"pre_install_query"` InstallScript TeamSpecSoftwareAsset `json:"install_script"` PostInstallScript TeamSpecSoftwareAsset `json:"post_install_script"` diff --git a/server/service/client.go b/server/service/client.go index 3111f19d72..607a0230c3 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -609,6 +609,7 @@ func (c *Client) ApplyGroup( softwarePayloads[i] = fleet.SoftwareInstallerPayload{ URL: si.URL, + SelfService: si.SelfService, PreInstallQuery: qc, InstallScript: string(ic), PostInstallScript: string(pc), diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index fae0a8cd98..6d6c24fa00 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -6602,7 +6602,7 @@ func (s *integrationTestSuite) TestListSoftwareAndSoftwareDetails() { // create a bunch of software sws := make([]fleet.Software, 20) for i := range sws { - sw := fleet.Software{Name: "sw" + strconv.Itoa(i), Version: "0.0." + strconv.Itoa(i), Source: "apps"} + sw := fleet.Software{Name: fmt.Sprintf("sw%02d", i), Version: "0.0." + strconv.Itoa(i), Source: "apps"} if i%2 == 0 { sw.Source = "chrome_extensions" sw.Browser = "chrome" diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 4f1d1a112b..c47b6f40a6 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -7004,6 +7004,16 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { sort.Slice(want, func(i, j int) bool { return want[i].Name < want[j].Name }) + for _, v := range got { + sort.Slice(v.Versions, func(i, j int) bool { + return v.Versions[i].Version < v.Versions[j].Version + }) + } + for _, v := range want { + sort.Slice(v.Versions, func(i, j int) bool { + return v.Versions[i].Version < v.Versions[j].Version + }) + } require.EqualValues(t, want, got) } @@ -9085,7 +9095,8 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { "name": teamName, "software": []map[string]any{ { - "url": "http://foo.com", + "url": "http://foo.com", + "self_service": true, "install_script": map[string]string{ "path": "./foo/install-script.sh", }, @@ -9117,12 +9128,14 @@ func (s *integrationEnterpriseTestSuite) TestApplyTeamsSoftwareConfig() { wantSoftware := []fleet.TeamSpecSoftware{ { URL: "http://foo.com", + SelfService: true, InstallScript: fleet.TeamSpecSoftwareAsset{Path: "./foo/install-script.sh"}, PostInstallScript: fleet.TeamSpecSoftwareAsset{Path: "./foo/post-install-script.sh"}, PreInstallQuery: fleet.TeamSpecSoftwareAsset{Path: "./foo/query.yaml"}, }, { URL: "http://bar.com", + SelfService: false, InstallScript: fleet.TeamSpecSoftwareAsset{Path: "./bar/install-script.sh"}, PostInstallScript: fleet.TeamSpecSoftwareAsset{Path: "./bar/post-install-script.sh"}, PreInstallQuery: fleet.TeamSpecSoftwareAsset{Path: "./bar/query.yaml"}, diff --git a/server/service/orbit_client_test.go b/server/service/orbit_client_test.go index 377d8b0c69..8f76595a60 100644 --- a/server/service/orbit_client_test.go +++ b/server/service/orbit_client_test.go @@ -149,10 +149,11 @@ func TestExecuteConfigReceiversCancel(t *testing.T) { func TestExecuteConfigReceiversInterrupt(t *testing.T) { client := clientWithConfig(&fleet.OrbitConfig{}) - client.ReceiverUpdateInterval = 200 * time.Millisecond + defer client.ReceiverUpdateCancelFunc() + + client.ReceiverUpdateInterval = 100 * time.Millisecond var called bool - rfunc := fleet.OrbitConfigReceiverFunc(func(cfg *fleet.OrbitConfig) error { called = true return nil @@ -160,14 +161,13 @@ func TestExecuteConfigReceiversInterrupt(t *testing.T) { client.RegisterConfigReceiver(rfunc) - finChan := make(chan error, 1) - + finChan := make(chan error) go func() { finChan <- client.ExecuteConfigReceivers() }() go func() { - time.Sleep(200 * time.Millisecond) + time.Sleep(500 * time.Millisecond) client.ReceiverUpdateCancelFunc() }() @@ -178,6 +178,4 @@ func TestExecuteConfigReceiversInterrupt(t *testing.T) { case <-time.NewTimer(2 * time.Second).C: require.Fail(t, "receiver interrupt cancel didn't work") } - - client.ReceiverUpdateCancelFunc() } From 16c4e0c411b94135034481d42e8574c62fa67c52 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 27 May 2024 10:53:41 -0400 Subject: [PATCH 04/16] Software SS: add self-service filter to list software titles and list host's/device's software (#19186) --- changes/18833-filter-software-by-self-service | 1 + server/datastore/mysql/software.go | 33 ++-- server/datastore/mysql/software_test.go | 153 +++++++++++------- server/datastore/mysql/software_titles.go | 27 ++-- .../datastore/mysql/software_titles_test.go | 39 +++++ server/fleet/datastore.go | 2 +- server/fleet/service.go | 2 +- server/fleet/software.go | 16 ++ server/mock/datastore_mock.go | 6 +- server/service/devices.go | 6 +- server/service/hosts.go | 23 +-- server/service/hosts_test.go | 6 +- server/service/integration_enterprise_test.go | 66 +++++++- 13 files changed, 265 insertions(+), 115 deletions(-) create mode 100644 changes/18833-filter-software-by-self-service diff --git a/changes/18833-filter-software-by-self-service b/changes/18833-filter-software-by-self-service new file mode 100644 index 0000000000..20381213a0 --- /dev/null +++ b/changes/18833-filter-software-by-self-service @@ -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. diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index eb018cb2fe..5252afdb40 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -1824,6 +1824,9 @@ func softwareInstallerHostStatusNamedQuery(tblAlias, colAlias string) string { if colAlias != "" { colAlias = " AS " + colAlias } + // the computed column assumes that all results (pre, install and post) are + // stored at once, so that if there is an exit code for the install script + // and none for the post-install, it is because there is no post-install. return fmt.Sprintf(` CASE WHEN %[1]spost_install_script_exit_code IS NOT NULL AND @@ -1847,11 +1850,11 @@ func softwareInstallerHostStatusNamedQuery(tblAlias, colAlias string) string { END %[2]s `, tblAlias, colAlias) } -func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { - // `status` computed column assumes that all results (pre, install and post) - // are stored at once, so that if there is an exit code for the install - // script and none for the post-install, it is because there is no - // post-install. +func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { + var onlySelfServiceClause string + if opts.SelfServiceOnly { + onlySelfServiceClause = ` AND si.self_service = 1 ` + } stmtInstalled := fmt.Sprintf(` SELECT st.id, @@ -1889,7 +1892,8 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc ) OR -- or software install has been attempted on host hsi.host_id IS NOT NULL ) -`, softwareInstallerHostStatusNamedQuery("hsi", "status")) + %s +`, softwareInstallerHostStatusNamedQuery("hsi", "status"), onlySelfServiceClause) const stmtAvailable = ` SELECT @@ -1928,6 +1932,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc hsi.software_installer_id = si.id ) AND si.global_or_team_id = (SELECT COALESCE(h.team_id, 0) FROM hosts h WHERE h.id = ?) + %s ` const selectColNames = ` @@ -1953,7 +1958,7 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc return nil, nil, ctxerr.Wrap(ctx, err, "build named query for list host software") } - if includeAvailableForInstall { + if opts.IncludeAvailableForInstall { platformArgs := []string{host.Platform} if fleet.IsLinux(host.Platform) { platformArgs = fleet.HostLinuxOSs @@ -1963,20 +1968,20 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc placeholders += "?," args = append(args, p) } - stmt += ` UNION ` + fmt.Sprintf(stmtAvailable, strings.TrimSuffix(placeholders, ",")) + stmt += ` UNION ` + fmt.Sprintf(stmtAvailable, strings.TrimSuffix(placeholders, ","), onlySelfServiceClause) args = append(args, host.ID, host.ID, host.ID) } stmt = selectColNames + ` FROM ( ` + stmt + ` ) AS tbl ` - if opts.MatchQuery != "" { + if opts.ListOptions.MatchQuery != "" { stmt += " WHERE TRUE " // searchLike adds a "AND " - stmt, args = searchLike(stmt, args, opts.MatchQuery, "name") + stmt, args = searchLike(stmt, args, opts.ListOptions.MatchQuery, "name") } // build the count statement before adding pagination constraints countStmt := fmt.Sprintf(`SELECT COUNT(DISTINCT s.id) FROM (%s) AS s`, stmt) - stmt, _ = appendListOptionsToSQL(stmt, &opts) + stmt, _ = appendListOptionsToSQL(stmt, &opts.ListOptions) // perform a second query to grab the titleCount var titleCount uint @@ -2114,14 +2119,14 @@ func (ds *Datastore) ListHostSoftware(ctx context.Context, host *fleet.Host, inc } } - perPage := opts.PerPage + perPage := opts.ListOptions.PerPage var metaData *fleet.PaginationMetadata - if opts.IncludeMetadata { + if opts.ListOptions.IncludeMetadata { if perPage <= 0 { perPage = defaultSelectLimit } metaData = &fleet.PaginationMetadata{ - HasPreviousResults: opts.Page > 0, + HasPreviousResults: opts.ListOptions.Page > 0, TotalResults: titleCount, } if len(hostSoftwareList) > int(perPage) { diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index aafda020fa..a04b396adb 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -2999,20 +2999,35 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { ctx := context.Background() host := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now(), test.WithPlatform("linux")) otherHost := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now()) - opts := fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"} + opts := fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 10, IncludeMetadata: true, OrderKey: "name", TestSecondaryOrderKey: "source"}} expectStatus := func(s fleet.SoftwareInstallerStatus) *fleet.SoftwareInstallerStatus { return &s } // no software yet - sw, meta, err := ds.ListHostSoftware(ctx, host, false, opts) + sw, meta, err := ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Empty(t, sw) require.Equal(t, &fleet.PaginationMetadata{}, meta) // works with available software too - sw, meta, err = ds.ListHostSoftware(ctx, host, true, opts) + opts.IncludeAvailableForInstall = true + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Empty(t, sw) + require.Equal(t, &fleet.PaginationMetadata{}, meta) + + // self-service only works too + opts.SelfServiceOnly = true + opts.IncludeAvailableForInstall = true + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) + require.NoError(t, err) + require.Empty(t, sw) + require.Equal(t, &fleet.PaginationMetadata{}, meta) + + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Empty(t, sw) require.Equal(t, &fleet.PaginationMetadata{}, meta) @@ -3155,7 +3170,9 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { } // it now returns the software with vulnerabilities and installed paths - sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.SelfServiceOnly = false + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 5}, meta) compareResults(expected, sw, true) @@ -3212,16 +3229,17 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { } res, err := q.ExecContext(ctx, ` INSERT INTO software_installers - (team_id, global_or_team_id, title_id, filename, version, install_script_content_id, storage_id, platform) + (team_id, global_or_team_id, title_id, filename, version, install_script_content_id, storage_id, platform, self_service) VALUES - (?, ?, ?, ?, ?, ?, unhex(?), ?)`, - teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), fmt.Sprintf("v%d.0.0", i), scriptContentID, hex.EncodeToString([]byte("test")), "linux") + (?, ?, ?, ?, ?, ?, unhex(?), ?, ?)`, + teamID, globalOrTeamID, titleID, fmt.Sprintf("installer-%d.pkg", i), fmt.Sprintf("v%d.0.0", i), scriptContentID, hex.EncodeToString([]byte("test")), "linux", i < 2) if err != nil { return err } id, _ := res.LastInsertId() swiIDs = append(swiIDs, uint(id)) } + // sw1Pending and swi2Installed are self-service installers swi1Pending, swi2Installed, swi3Failed, swi4Available, swi5Tm = swiIDs[0], swiIDs[1], swiIDs[2], swiIDs[3], swiIDs[4] // create the results for the host @@ -3318,7 +3336,8 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { expected[i1.Name+i1.Source] = i1 // request without available software - sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta) compareResults(expected, sw, true) @@ -3342,20 +3361,22 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { } expected[i3.Name+i3.Source] = i3 - sw, meta, err = ds.ListHostSoftware(ctx, host, true, opts) + opts.IncludeAvailableForInstall = true + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 8}, meta) compareResults(expected, sw, true, i3.Name+i3.Source) // request in descending order - opts.OrderDirection = fleet.OrderDescending - opts.TestSecondaryOrderDirection = fleet.OrderDescending - sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.ListOptions.OrderDirection = fleet.OrderDescending + opts.ListOptions.TestSecondaryOrderDirection = fleet.OrderDescending + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta) compareResults(expected, sw, false, i2.Name+i2.Source, i3.Name+i3.Source) - opts.OrderDirection = fleet.OrderAscending - opts.TestSecondaryOrderDirection = fleet.OrderAscending + opts.ListOptions.OrderDirection = fleet.OrderAscending + opts.ListOptions.TestSecondaryOrderDirection = fleet.OrderAscending // record a new install request for i1, this time as pending, and mark install request for b (swi1) as failed time.Sleep(time.Second) // ensure the timestamp is later @@ -3397,13 +3418,15 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { } // request without available software - sw, meta, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 7}, meta) compareResults(expected, sw, true, i2.Name+i2.Source, i3.Name+i3.Source) // request with available software) - sw, meta, err = ds.ListHostSoftware(ctx, host, true, opts) + opts.IncludeAvailableForInstall = true + sw, meta, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 8}, meta) compareResults(expected, sw, true, i3.Name+i3.Source) @@ -3414,13 +3437,15 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { require.NoError(t, err) // no installed software for this host - sw, meta, err = ds.ListHostSoftware(ctx, tmHost, false, opts) + opts.IncludeAvailableForInstall = false + sw, meta, err = ds.ListHostSoftware(ctx, tmHost, opts) require.NoError(t, err) require.Empty(t, sw) require.Equal(t, &fleet.PaginationMetadata{}, meta) // sees the available installer in its team - sw, meta, err = ds.ListHostSoftware(ctx, tmHost, true, opts) + opts.IncludeAvailableForInstall = true + sw, meta, err = ds.ListHostSoftware(ctx, tmHost, opts) require.NoError(t, err) require.Equal(t, &fleet.PaginationMetadata{TotalResults: 1}, meta) compareResults(map[string]fleet.HostSoftwareWithInstaller{ @@ -3428,86 +3453,92 @@ func testListHostSoftware(t *testing.T, ds *Datastore) { }, sw, true) // test with a search query (searches on name), with and without available software - opts.MatchQuery = "a" - sw, _, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.ListOptions.MatchQuery = "a" + opts.IncludeAvailableForInstall = false + sw, _, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) compareResults(map[string]fleet.HostSoftwareWithInstaller{ byNSV[a1].Name + byNSV[a1].Source: expected[byNSV[a1].Name+byNSV[a1].Source], byNSV[a2].Name + byNSV[a2].Source: expected[byNSV[a2].Name+byNSV[a2].Source], }, sw, true) - sw, _, err = ds.ListHostSoftware(ctx, host, true, opts) + opts.IncludeAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) compareResults(map[string]fleet.HostSoftwareWithInstaller{ byNSV[a1].Name + byNSV[a1].Source: expected[byNSV[a1].Name+byNSV[a1].Source], byNSV[a2].Name + byNSV[a2].Source: expected[byNSV[a2].Name+byNSV[a2].Source], }, sw, true) - opts.MatchQuery = "zz" - sw, _, err = ds.ListHostSoftware(ctx, host, false, opts) + opts.ListOptions.MatchQuery = "zz" + opts.IncludeAvailableForInstall = false + sw, _, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Empty(t, sw) - sw, _, err = ds.ListHostSoftware(ctx, host, true, opts) + opts.IncludeAvailableForInstall = true + sw, _, err = ds.ListHostSoftware(ctx, host, opts) require.NoError(t, err) require.Empty(t, sw) // test the pagination cases := []struct { - opts fleet.ListOptions - withAvailable bool - wantNames []string - wantMeta *fleet.PaginationMetadata + opts fleet.HostSoftwareTitleListOptions + wantNames []string + wantMeta *fleet.PaginationMetadata }{ { - opts: fleet.ListOptions{PerPage: 3}, - withAvailable: false, - wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 7}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 3}, IncludeAvailableForInstall: false}, + wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 7}, }, { - opts: fleet.ListOptions{Page: 1, PerPage: 3}, - withAvailable: false, - wantNames: []string{byNSV[c1].Name, byNSV[d].Name, i0.Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 7}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 3}, IncludeAvailableForInstall: false}, + wantNames: []string{byNSV[c1].Name, byNSV[d].Name, i0.Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true, TotalResults: 7}, }, { - opts: fleet.ListOptions{Page: 2, PerPage: 3}, - withAvailable: false, - wantNames: []string{i1.Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 3}, IncludeAvailableForInstall: false}, + wantNames: []string{i1.Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, }, { - opts: fleet.ListOptions{Page: 3, PerPage: 3}, - withAvailable: false, - wantNames: []string{}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 3, PerPage: 3}, IncludeAvailableForInstall: false}, + wantNames: []string{}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 7}, }, { - opts: fleet.ListOptions{PerPage: 4}, - withAvailable: true, - wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 8}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 4}, IncludeAvailableForInstall: true}, + wantNames: []string{byNSV[a1].Name, byNSV[a2].Name, byNSV[b].Name, byNSV[c1].Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false, TotalResults: 8}, }, { - opts: fleet.ListOptions{Page: 1, PerPage: 4}, - withAvailable: true, - wantNames: []string{byNSV[d].Name, i0.Name, i1.Name, i2.Name}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 4}, IncludeAvailableForInstall: true}, + wantNames: []string{byNSV[d].Name, i0.Name, i1.Name, i2.Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, }, { - opts: fleet.ListOptions{Page: 2, PerPage: 4}, - withAvailable: true, - wantNames: []string{}, - wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 2, PerPage: 4}, IncludeAvailableForInstall: true}, + wantNames: []string{}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 8}, + }, + { + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{PerPage: 2}, IncludeAvailableForInstall: true, SelfServiceOnly: true}, + wantNames: []string{byNSV[b].Name, i0.Name}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false, TotalResults: 2}, + }, + { + opts: fleet.HostSoftwareTitleListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 2}, IncludeAvailableForInstall: true, SelfServiceOnly: true}, + wantNames: []string{}, + wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true, TotalResults: 2}, }, } for _, c := range cases { - t.Run(fmt.Sprintf("%t: %#v", c.withAvailable, c.opts), func(t *testing.T) { + t.Run(fmt.Sprintf("%#v", c.opts), func(t *testing.T) { // always include metadata - c.opts.IncludeMetadata = true - c.opts.OrderKey = "name" - c.opts.TestSecondaryOrderKey = "source" + c.opts.ListOptions.IncludeMetadata = true + c.opts.ListOptions.OrderKey = "name" + c.opts.ListOptions.TestSecondaryOrderKey = "source" - sw, meta, err := ds.ListHostSoftware(ctx, host, c.withAvailable, c.opts) + sw, meta, err := ds.ListHostSoftware(ctx, host, c.opts) require.NoError(t, err) require.Equal(t, len(c.wantNames), len(sw)) diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index bd98f58b03..bbe714b452 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -35,7 +35,7 @@ SELECT MAX(sthc.updated_at) as counts_updated_at FROM software_titles st LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND %s -WHERE st.id = ? +WHERE st.id = ? AND (sthc.hosts_count > 0 OR EXISTS (SELECT 1 FROM software_installers si WHERE si.title_id = st.id AND si.global_or_team_id = ?)) GROUP BY st.id, @@ -204,7 +204,7 @@ SELECT MAX(COALESCE(sthc.updated_at, date('0001-01-01 00:00:00'))) as counts_updated_at, si.filename as software_package FROM software_titles st -LEFT JOIN software_installers si ON si.title_id = st.id +LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = ? LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND sthc.team_id = ? -- placeholder for JOIN on software/software_cve %s @@ -219,11 +219,9 @@ GROUP BY st.id, software_package` cveJoinType = "INNER" } - var globalOrTeamID uint - args := []any{0} + args := []any{0, 0} if opt.TeamID != nil { - args[0] = *opt.TeamID - globalOrTeamID = *opt.TeamID + args[0], args[1] = *opt.TeamID, *opt.TeamID } additionalWhere := "TRUE" @@ -247,15 +245,9 @@ GROUP BY st.id, software_package` args = append(args, match, match) } + // default to "a software installer exists", and see next condition. defaultFilter := ` - EXISTS ( - SELECT 1 - FROM - software_installers si - WHERE - si.title_id = st.id - AND si.global_or_team_id = ? - ) + si.id IS NOT NULL ` // add software installed for hosts if any of this is true: @@ -263,10 +255,11 @@ GROUP BY st.id, software_package` // - we're not filtering for "available for install" only // - we're filtering by vulnerable only if !opt.AvailableForInstall || opt.VulnerableOnly { - defaultFilter += `OR sthc.hosts_count > 0` + defaultFilter = ` ( ` + defaultFilter + ` OR sthc.hosts_count > 0 ) ` + } + if opt.SelfServiceOnly { + defaultFilter += ` AND si.self_service = 1 ` } - - args = append(args, globalOrTeamID) stmt = fmt.Sprintf(stmt, softwareJoin, additionalWhere, defaultFilter) return stmt, args diff --git a/server/datastore/mysql/software_titles_test.go b/server/datastore/mysql/software_titles_test.go index ea70177055..85f4fb8819 100644 --- a/server/datastore/mysql/software_titles_test.go +++ b/server/datastore/mysql/software_titles_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/fleetdm/fleet/v4/server/fleet" @@ -297,6 +298,11 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { }) require.NoError(t, err) require.NotZero(t, installer1) + // make installer1 "self-service" available + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE software_installers SET self_service = 1 WHERE id = ?`, installer1) + return err + }) // create a software installer with an install request on host1 installer2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer2", @@ -455,6 +461,16 @@ func testOrderSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, "apps", titles[0].Source) require.Equal(t, "installer1", titles[1].Name) require.Equal(t, "apps", titles[1].Source) + + // filter on self-service only + titles, _, _, err = ds.ListSoftwareTitles(ctx, fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{ + OrderKey: "name", + OrderDirection: fleet.OrderDescending, + }, SelfServiceOnly: true}, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) + require.NoError(t, err) + require.Len(t, titles, 1) + require.Equal(t, "installer1", titles[0].Name) + require.Equal(t, "apps", titles[0].Source) } func listSoftwareTitlesCheckCount(t *testing.T, ds *Datastore, expectedListCount int, expectedFullCount int, opts fleet.SoftwareTitleListOptions) []fleet.SoftwareTitleListResult { @@ -509,6 +525,11 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { }) require.NoError(t, err) require.NotZero(t, installer1) + // make installer1 "self-service" available + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `UPDATE software_installers SET self_service = 1 WHERE id = ?`, installer1) + return err + }) // create a software installer for team2 installer2, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "installer2", @@ -605,6 +626,24 @@ func testTeamFilterSoftwareTitles(t *testing.T, ds *Datastore) { require.Equal(t, uint(1), titles[0].VersionsCount) require.Equal(t, uint(1), titles[1].VersionsCount) require.Equal(t, uint(0), titles[2].VersionsCount) + + // Testing the team 1 user with self-service only + titles, _, _, err = ds.ListSoftwareTitles( + context.Background(), fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{}, SelfServiceOnly: true, TeamID: &team1.ID}, team1TeamFilter, + ) + // installer1 is associated with team 1 + require.NoError(t, err) + require.Len(t, titles, 1) + require.Equal(t, "installer1", titles[0].Name) + require.Equal(t, "apps", titles[0].Source) + + // Testing the team 2 user with self-service only + titles, _, _, err = ds.ListSoftwareTitles(context.Background(), fleet.SoftwareTitleListOptions{ListOptions: fleet.ListOptions{}, SelfServiceOnly: true, TeamID: &team2.ID}, fleet.TeamFilter{ + User: userTeam2Admin, + IncludeObserver: true, + }) + require.NoError(t, err) + require.Len(t, titles, 0) } func sortTitlesByName(titles []fleet.SoftwareTitleListResult) { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 0c48de6370..ae1356bb7a 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -551,7 +551,7 @@ type Datastore interface { InsertCVEMeta(ctx context.Context, cveMeta []CVEMeta) error ListCVEs(ctx context.Context, maxAge time.Duration) ([]CVEMeta, error) - ListHostSoftware(ctx context.Context, host *Host, includeAvailableForInstall bool, opts ListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error) + ListHostSoftware(ctx context.Context, host *Host, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error) // SetHostSoftwareInstallResult records the result of a software installation // attempt on the host. diff --git a/server/fleet/service.go b/server/fleet/service.go index f90d8b32c3..9d55f3243a 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -415,7 +415,7 @@ type Service interface { // ListHostSoftware lists the software installed or available for install on // the specified host. - ListHostSoftware(ctx context.Context, hostID uint, opts ListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error) + ListHostSoftware(ctx context.Context, hostID uint, opts HostSoftwareTitleListOptions) ([]*HostSoftwareWithInstaller, *PaginationMetadata, error) // ///////////////////////////////////////////////////////////////////////////// // AppConfigService provides methods for configuring the Fleet application diff --git a/server/fleet/software.go b/server/fleet/software.go index 0842d74933..406536295d 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -193,6 +193,22 @@ type SoftwareTitleListOptions struct { TeamID *uint `query:"team_id,optional"` VulnerableOnly bool `query:"vulnerable,optional"` AvailableForInstall bool `query:"available_for_install,optional"` + SelfServiceOnly bool `query:"self_service,optional"` +} + +type HostSoftwareTitleListOptions struct { + // ListOptions cannot be embedded in order to unmarshal with validation. + ListOptions ListOptions `url:"list_options"` + + // SelfServiceOnly limits the returned software titles to those that are + // available to install by the end user via the self-service. Implies + // AvailableForInstall. + SelfServiceOnly bool `query:"self_service,optional"` + + // IncludeAvailableForInstall is not a query argument, it is set in the + // service layer to indicate to the datastore if software available for + // install (but not currently installed on the host) should be returned. + IncludeAvailableForInstall bool } // AuthzSoftwareInventory is used for access controls on software inventory. diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index d37ee164ad..08f791b054 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -403,7 +403,7 @@ type InsertCVEMetaFunc func(ctx context.Context, cveMeta []fleet.CVEMeta) error type ListCVEsFunc func(ctx context.Context, maxAge time.Duration) ([]fleet.CVEMeta, error) -type ListHostSoftwareFunc func(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) +type ListHostSoftwareFunc func(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) type SetHostSoftwareInstallResultFunc func(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error @@ -3702,11 +3702,11 @@ func (s *DataStore) ListCVEs(ctx context.Context, maxAge time.Duration) ([]fleet return s.ListCVEsFunc(ctx, maxAge) } -func (s *DataStore) ListHostSoftware(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { +func (s *DataStore) ListHostSoftware(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { s.mu.Lock() s.ListHostSoftwareFuncInvoked = true s.mu.Unlock() - return s.ListHostSoftwareFunc(ctx, host, includeAvailableForInstall, opts) + return s.ListHostSoftwareFunc(ctx, host, opts) } func (s *DataStore) SetHostSoftwareInstallResult(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error { diff --git a/server/service/devices.go b/server/service/devices.go index 41cd5dc8dd..91508d7839 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -596,8 +596,8 @@ func (svc *Service) TriggerMigrateMDMDevice(ctx context.Context, host *fleet.Hos //////////////////////////////////////////////////////////////////////////////// type getDeviceSoftwareRequest struct { - Token string `url:"token"` - ListOptions fleet.ListOptions `url:"list_options"` + Token string `url:"token"` + fleet.HostSoftwareTitleListOptions } func (r *getDeviceSoftwareRequest) deviceAuthToken() string { @@ -621,7 +621,7 @@ func getDeviceSoftwareEndpoint(ctx context.Context, request interface{}, svc fle } req := request.(*getDeviceSoftwareRequest) - res, meta, err := svc.ListHostSoftware(ctx, host.ID, req.ListOptions) + res, meta, err := svc.ListHostSoftware(ctx, host.ID, req.HostSoftwareTitleListOptions) if err != nil { return getDeviceSoftwareResponse{Err: err}, nil } diff --git a/server/service/hosts.go b/server/service/hosts.go index 5d51946edf..834a7ee07a 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -2469,8 +2469,8 @@ func (svc *Service) validateLabelNames(ctx context.Context, action string, label //////////////////////////////////////////////////////////////////////////////// type getHostSoftwareRequest struct { - ID uint `url:"id"` - ListOptions fleet.ListOptions `url:"list_options"` + ID uint `url:"id"` + fleet.HostSoftwareTitleListOptions } type getHostSoftwareResponse struct { @@ -2484,7 +2484,7 @@ func (r getHostSoftwareResponse) error() error { return r.Err } func getHostSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*getHostSoftwareRequest) - res, meta, err := svc.ListHostSoftware(ctx, req.ID, req.ListOptions) + res, meta, err := svc.ListHostSoftware(ctx, req.ID, req.HostSoftwareTitleListOptions) if err != nil { return getHostSoftwareResponse{Err: err}, nil } @@ -2494,9 +2494,11 @@ func getHostSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet return getHostSoftwareResponse{Software: res, Meta: meta, Count: int(meta.TotalResults)}, nil } -func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { - // if the request is token-authenticated ("My device" page), we don't include software - // that is not installed but for which there's an installer available for that host. +func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { + // if the request is token-authenticated ("My device" page), we don't include + // software that is not installed but for which there's an installer + // available for that host (unless the request filters for self-service + // software only). var includeAvailableForInstall bool var host *fleet.Host @@ -2526,13 +2528,14 @@ func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts flee } // cursor-based pagination is not supported - opts.After = "" + opts.ListOptions.After = "" // custom ordering is not supported, always by name (but asc/desc is configurable) - opts.OrderKey = "name" + opts.ListOptions.OrderKey = "name" // always include metadata - opts.IncludeMetadata = true + opts.ListOptions.IncludeMetadata = true + opts.IncludeAvailableForInstall = includeAvailableForInstall || opts.SelfServiceOnly - software, meta, err := svc.ds.ListHostSoftware(ctx, host, includeAvailableForInstall, opts) + software, meta, err := svc.ds.ListHostSoftware(ctx, host, opts) if !includeAvailableForInstall { // for the device page, we don't want to return the package name for _, s := range software { diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 49a824b335..68e924a172 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -618,7 +618,7 @@ func TestHostAuth(t *testing.T) { ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } - ds.ListHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeAvailableForInstall bool, opts fleet.ListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { + ds.ListHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) { return nil, nil, nil } @@ -763,10 +763,10 @@ func TestHostAuth(t *testing.T) { _, err = svc.SetCustomHostDeviceMapping(ctx, 2, "a@b.c") checkAuthErr(t, tt.shouldFailGlobalWrite, err) - _, _, err = svc.ListHostSoftware(ctx, 1, fleet.ListOptions{}) + _, _, err = svc.ListHostSoftware(ctx, 1, fleet.HostSoftwareTitleListOptions{}) checkAuthErr(t, tt.shouldFailTeamRead, err) - _, _, err = svc.ListHostSoftware(ctx, 2, fleet.ListOptions{}) + _, _, err = svc.ListHostSoftware(ctx, 2, fleet.HostSoftwareTitleListOptions{}) checkAuthErr(t, tt.shouldFailGlobalRead, err) }) } diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index c47b6f40a6..2dd12b61e5 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -80,7 +80,11 @@ func (s *integrationEnterpriseTestSuite) SetupSuite() { return func() (fleet.CronSchedule, error) { // We set 24-hour interval so that it only runs when triggered. var err error - calendarSchedule, err = cron.NewCalendarSchedule(ctx, s.T().Name(), s.ds, 24*time.Hour, log.NewJSONLogger(os.Stdout)) + cronLog := log.NewJSONLogger(os.Stdout) + if os.Getenv("FLEET_INTEGRATION_TESTS_DISABLE_LOG") != "" { + cronLog = kitlog.NewNopLogger() + } + calendarSchedule, err = cron.NewCalendarSchedule(ctx, s.T().Name(), s.ds, 24*time.Hour, cronLog) return calendarSchedule, err } }, @@ -7128,6 +7132,9 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { require.NoError(t, s.ds.SyncHostsSoftwareTitles(ctx, hostsCountTs)) var resp listSoftwareTitlesResponse + // no self-service software yet + s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "self_service", "1") + require.Empty(t, resp.SoftwareTitles) s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp) require.Equal(t, 2, resp.Count) require.NotEmpty(t, resp.CountsUpdatedAt) @@ -7643,7 +7650,22 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { http.StatusOK, &resp, "query", "ruby", ) + require.Len(t, resp.SoftwareTitles, 1) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + require.Equal(t, "ruby.deb", *resp.SoftwareTitles[0].SoftwarePackage) + // software installer not returned with self-service only (not marked as such) + resp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "self_service", "1", "query", "ruby") + require.Len(t, resp.SoftwareTitles, 0) + + // update it to be self-service, check that it gets returned + mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, "UPDATE software_installers SET self_service = 1 WHERE filename = ?", payload.Filename) + return err + }) + resp = listSoftwareTitlesResponse{} + s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "self_service", "1", "query", "ruby") require.Len(t, resp.SoftwareTitles, 1) require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) require.Equal(t, "ruby.deb", *resp.SoftwareTitles[0].SoftwarePackage) @@ -8755,12 +8777,18 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { var getHostSw getHostSoftwareResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) require.Len(t, getHostSw.Software, 0) + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true") + require.Len(t, getHostSw.Software, 0) var getDeviceSw getDeviceSoftwareResponse res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) err := json.NewDecoder(res.Body).Decode(&getDeviceSw) require.NoError(t, err) require.Len(t, getDeviceSw.Software, 0) + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK) + err = json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Len(t, getDeviceSw.Software, 0) // create some software for that host software := []fleet.Software{ @@ -8797,6 +8825,12 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { s.uploadSoftwareInstaller(payload, http.StatusOK, "") titleID := getSoftwareTitleID(t, s.ds, "ruby", "deb_packages") + // update it to be self-service + mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { + _, err := tx.ExecContext(ctx, "UPDATE software_installers SET self_service = 1 WHERE filename = ?", payload.Filename) + return err + }) + // available installer is returned by user-authenticated endpoint getHostSw = getHostSoftwareResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw) @@ -8809,6 +8843,12 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Equal(t, "ruby.deb", *getHostSw.Software[2].PackageAvailableForInstall) require.Nil(t, getHostSw.Software[2].Status) + // only the installer is returned for self-service only + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true") + require.Len(t, getHostSw.Software, 1) + require.Equal(t, getHostSw.Software[0].Name, "ruby") + // available installer is not returned by device-authenticated endpoint res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) getDeviceSw = getDeviceSoftwareResponse{} @@ -8821,6 +8861,14 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.Nil(t, getDeviceSw.Software[0].PackageAvailableForInstall) require.Nil(t, getDeviceSw.Software[1].PackageAvailableForInstall) + // but it gets returned for self-service only + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK) + getDeviceSw = getDeviceSoftwareResponse{} + err = json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Len(t, getDeviceSw.Software, 1) + require.Equal(t, getDeviceSw.Software[0].Name, "ruby") + // request installation on the host var installResp installSoftwareResponse s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/software/install/%d", @@ -8839,7 +8887,13 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.NotNil(t, getHostSw.Software[2].Status) require.Equal(t, fleet.SoftwareInstallerPending, *getHostSw.Software[2].Status) - // now returned by device-authenticated endpoin + // still returned with self-service filter + getHostSw = getHostSoftwareResponse{} + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "self_service", "true") + require.Len(t, getHostSw.Software, 1) + require.Equal(t, getHostSw.Software[0].Name, "ruby") + + // now returned by device-authenticated endpoint res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software", nil, http.StatusOK) getDeviceSw = getDeviceSoftwareResponse{} err = json.NewDecoder(res.Body).Decode(&getDeviceSw) @@ -8853,6 +8907,14 @@ func (s *integrationEnterpriseTestSuite) TestListHostSoftware() { require.NotNil(t, getDeviceSw.Software[2].Status) require.Equal(t, fleet.SoftwareInstallerPending, *getDeviceSw.Software[2].Status) + // still returned for self-service only too + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/software?self_service=1", nil, http.StatusOK) + getDeviceSw = getDeviceSoftwareResponse{} + err = json.NewDecoder(res.Body).Decode(&getDeviceSw) + require.NoError(t, err) + require.Len(t, getDeviceSw.Software, 1) + require.Equal(t, getDeviceSw.Software[0].Name, "ruby") + // test with a query getHostSw = getHostSoftwareResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/software", host.ID), nil, http.StatusOK, &getHostSw, "query", "foo") From 669d84128ecaf7cb078937ae1e39102a15174c37 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Mon, 27 May 2024 14:00:03 -0400 Subject: [PATCH 05/16] Software SS: add menu item to Fleet Desktop (#19289) --- orbit/changes/18835-add-fleet-desktop-self-service | 1 + orbit/cmd/desktop/desktop.go | 11 +++++++++++ server/service/device_client.go | 9 +++++++++ 3 files changed, 21 insertions(+) create mode 100644 orbit/changes/18835-add-fleet-desktop-self-service diff --git a/orbit/changes/18835-add-fleet-desktop-self-service b/orbit/changes/18835-add-fleet-desktop-self-service new file mode 100644 index 0000000000..c0d54803d1 --- /dev/null +++ b/orbit/changes/18835-add-fleet-desktop-self-service @@ -0,0 +1 @@ +* Added the `Self-service` menu item to Fleet Desktop. diff --git a/orbit/cmd/desktop/desktop.go b/orbit/cmd/desktop/desktop.go index 89b8e88475..64fbdc4c49 100644 --- a/orbit/cmd/desktop/desktop.go +++ b/orbit/cmd/desktop/desktop.go @@ -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) diff --git a/server/service/device_client.go b/server/service/device_client.go index 8d89378ee4..64e3c36c58 100644 --- a/server/service/device_client.go +++ b/server/service/device_client.go @@ -125,6 +125,15 @@ func (dc *DeviceClient) BrowserTransparencyURL(token string) string { return transparencyURL.String() } +// BrowserSelfServiceURL returns the "Self-service" URL for the browser. +func (dc *DeviceClient) BrowserSelfServiceURL(token string) string { + selfServiceURL := dc.baseClient.url("/device/"+token+"/self-service", "") + if dc.fleetAlternativeBrowserHost != "" { + selfServiceURL.Host = dc.fleetAlternativeBrowserHost + } + return selfServiceURL.String() +} + // BrowserDeviceURL returns the "My device" URL for the browser. func (dc *DeviceClient) BrowserDeviceURL(token string) string { deviceURL := dc.baseClient.url("/device/"+token, "") From 7193d0e52f14d2959f4ced787904eede05439d9a Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Mon, 27 May 2024 15:44:31 -0400 Subject: [PATCH 06/16] Add software self_service bool to software titles list (#19258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an endpoint I forgot as part of #19212 😬 --- cmd/fleetctl/get_test.go | 4 +++ server/datastore/mysql/software_titles.go | 5 +-- server/fleet/software.go | 2 ++ server/fleet/software_installer.go | 2 +- server/service/integration_enterprise_test.go | 34 ++++++++++++++++++ .../testdata/software-installers/emacs.deb | Bin 0 -> 16252 bytes 6 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 server/service/testdata/software-installers/emacs.deb diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 5fbc221fb7..efb9ca6621 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -677,6 +677,7 @@ spec: - hosts_count: 2 id: 0 name: foo + self_service: false software_package: null source: chrome_extensions versions: @@ -697,6 +698,7 @@ spec: - hosts_count: 0 id: 0 name: bar + self_service: false software_package: null source: deb_packages versions: @@ -741,6 +743,7 @@ spec: ] } ], + "self_service": false, "software_package": null }, { @@ -756,6 +759,7 @@ spec: "vulnerabilities": null } ], + "self_service": false, "software_package": null } ] diff --git a/server/datastore/mysql/software_titles.go b/server/datastore/mysql/software_titles.go index bbe714b452..4913ac0f09 100644 --- a/server/datastore/mysql/software_titles.go +++ b/server/datastore/mysql/software_titles.go @@ -202,7 +202,8 @@ SELECT st.browser, MAX(COALESCE(sthc.hosts_count, 0)) as hosts_count, MAX(COALESCE(sthc.updated_at, date('0001-01-01 00:00:00'))) as counts_updated_at, - si.filename as software_package + si.filename as software_package, + COALESCE(si.self_service, false) as self_service FROM software_titles st LEFT JOIN software_installers si ON si.title_id = st.id AND si.global_or_team_id = ? LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND sthc.team_id = ? @@ -212,7 +213,7 @@ LEFT JOIN software_titles_host_counts sthc ON sthc.software_title_id = st.id AND WHERE %s -- placeholder for filter based on software installed on hosts + software installers AND (%s) -GROUP BY st.id, software_package` +GROUP BY st.id, software_package, si.self_service` cveJoinType := "LEFT" if opt.VulnerableOnly { diff --git a/server/fleet/software.go b/server/fleet/software.go index 406536295d..6cab811cc4 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -184,6 +184,8 @@ type SoftwareTitleListResult struct { CountsUpdatedAt *time.Time `json:"-" db:"counts_updated_at"` // SoftwarePackage is the filename of the installer for this software title. SoftwarePackage *string `json:"software_package" db:"software_package"` + // SelfService indicates if the end user can initiate the installation + SelfService bool `json:"self_service" db:"self_service"` } type SoftwareTitleListOptions struct { diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 6f6073e2fd..99085a3a99 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -97,7 +97,7 @@ type SoftwareInstaller struct { SoftwareTitle string `json:"-" db:"software_title"` // SelfService indicates that the software can be installed by the // end user without admin intervention - SelfService bool `json:"-" db:"self_service"` + SelfService bool `json:"self_service" db:"self_service"` } // AuthzType implements authz.AuthzTyper. diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 2dd12b61e5..3ec0227842 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -7148,6 +7148,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { {Version: "0.0.1", Vulnerabilities: nil}, {Version: "0.0.3", Vulnerabilities: nil}, }, + SelfService: false, }, { Name: "bar", @@ -7157,6 +7158,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { Versions: []fleet.SoftwareVersion{ {Version: "0.0.4", Vulnerabilities: &fleet.SliceString{"cve-123-123-132"}}, }, + SelfService: false, }, }, resp.SoftwareTitles) @@ -7640,9 +7642,17 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { payload := &fleet.UploadSoftwareInstallerPayload{ InstallScript: "install", Filename: "ruby.deb", + SelfService: false, } s.uploadSoftwareInstaller(payload, http.StatusOK, "") + payloadEmacs := &fleet.UploadSoftwareInstallerPayload{ + InstallScript: "install", + Filename: "emacs.deb", + SelfService: true, + } + s.uploadSoftwareInstaller(payloadEmacs, http.StatusOK, "") + resp = listSoftwareTitlesResponse{} s.DoJSON( "GET", "/api/latest/fleet/software/titles", @@ -7669,6 +7679,30 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { require.Len(t, resp.SoftwareTitles, 1) require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) require.Equal(t, "ruby.deb", *resp.SoftwareTitles[0].SoftwarePackage) + require.True(t, *&resp.SoftwareTitles[0].SelfService) + + resp = listSoftwareTitlesResponse{} + s.DoJSON( + "GET", "/api/latest/fleet/software/titles", + listSoftwareTitlesRequest{}, + http.StatusOK, &resp, + "self_service", "true", + ) + + require.Len(t, resp.SoftwareTitles, 2) + require.NotNil(t, resp.SoftwareTitles[0].SoftwarePackage) + require.Equal(t, "emacs.deb", *resp.SoftwareTitles[0].SoftwarePackage) + require.True(t, *&resp.SoftwareTitles[0].SelfService) + + emacsPath := fmt.Sprintf("/api/latest/fleet/software/titles/%d", resp.SoftwareTitles[0].ID) + respTitle := getSoftwareTitleResponse{} + s.DoJSON("GET", emacsPath, listSoftwareTitlesRequest{}, http.StatusOK, &respTitle) + + require.NotNil(t, respTitle.SoftwareTitle) + require.Equal(t, "emacs.deb", respTitle.SoftwareTitle.SoftwarePackage.Name) + fmt.Printf("respTitle.SoftwareTitle.SoftwarePackage: %+v\n", respTitle.SoftwareTitle.SoftwarePackage) + require.True(t, respTitle.SoftwareTitle.SoftwarePackage.SelfService) + } func (s *integrationEnterpriseTestSuite) TestLockUnlockWipeWindowsLinux() { diff --git a/server/service/testdata/software-installers/emacs.deb b/server/service/testdata/software-installers/emacs.deb new file mode 100644 index 0000000000000000000000000000000000000000..90c58f1045de885f56dbd9c278c44d98035e881d GIT binary patch literal 16252 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μ%8@{)=pWa z+7CN7t*fw=bL%V^ctZtjo4~P43Ca0^MTcNX;*0Sk8E0`yKsi{>0@y{MQjhj4?rXMg zd^S%vSovsPQ*xjXdaqLW5Kl+++={J_e{z&zLumN^@3F2xw&b)e8Ul8Ny=7 zb~(^>Qm>PSX)(_GW!JuUC!)8S|KXbJz<^D1q8G)o6Y)fqzMAk+VcvSd^xeltnof|- z;`}W|BfEm3{oyVFgq2USpPf4R^STp^rQah-?}LZLnN%bVyWU*79PKWhDF?m#3pvTG z2Mc?Wt$ZM;Wm2ekKI?)%JSPyXvRJ$+Dx_vNuJX>B+$5?NZ$1UFKsAtO+diDhLjinF z3_GAc&~tbJVs)kXoiiDeog64`lqWL`ZP+5xp$IepxhL?nyLo*Pl#xMc1&$l0C5dWw zXvC%HA5{_N?j2`gp6}K4^ped5AxyG_IHC)RYAOf*P($O|OBxk5JqqoygcZ~oaavT(}Fwv^9^?ciL`b<9PXu$E>*W4bR375-3{wG{{X zPLS9g1p6DAYT%RHDNkX442-BC!2N}~O0${-wZIykNt{@tc&HJc#hw_uE~ZWmh-cnAb}FO3>Gz_OiovA*!gTRebrem~v2D1v!ij*a{QO+axc?TRSqOfc z#~JU}la*^4H7R!rF|xGm% zkaYM5doULtxGN_#AZ)*_J$c!LYQXbW51(ocjI05WX$wIy1IO%f_iPK{hD^(ykGHL; zmFia)>R@Psrmkre20;^+IoWz%tZ+oALfj_tE$6f6**E^D@(8Ws<$m<=wX3tt@dPP} zO9_+mVI^C{M>dk#f*@4%VHW~abbeI5zlbJmV~XLy^p8HkR!9?mZ^`Mh(tVYg5;gBq z93LFk4z22D0p9mMqq;wkgEHo4CmAO*t*dOwyJDejdP8a8mXSPqK=dvh)SgRm7-!cA zT(gXNs+K^&j>v&kO^TiN-n%Dg15Tz#q}0TlPO@u2y3kfEEb&X^d3Saxn+Ta)iKgfc z^(@uL6m6lI4mmgw|HjTp%j!jV3ou&3= z_hlhWfulm`EJnLq_YN5jqBT(n!C&xY&g63$eL8vI-8~6jghzYQcJ6Ype_o@gyA3KM z1*R!gld$|yr}L%UqgHz>dZ!#HNJXwup?##dG_#88#!lQcC5j&bST8BP2$UP)kN!Fy zUvYkI+zkD5sUUfe(kb%?EvGcKEHQQK4@w$^D!rF=+5Luma(eKg6=C942{n(4#M>ku zBB#+0vOiOgq?$^*;z%Xitsf$=^=AmlQU05RIf8PfTN7USkV7sd7Jzx&3)^b9PiQE( z*2dIx`Nsp0NIx&#YZ+vQj0}GWkS@+|ff`_vHF92KNJxzGIxdTx5QjYbj=gpJRh^#z z1Kq`u6}(l>PtU;CdCFJSP)*G1AH2DkTntchXBV69=YY$ddCt4eG8dCT@#6_2uMGB6 z2GH?XSyTV$eFFbq94ZOz3n^>^P4#`Q9KF&lya%{Rmw zZ&q?Fdt5ij`D@q}UEaX>7)@*8bXF03uPFFk-Ck^O=6b0nTF+0S%hyA}J?zc}rNmkD*Cb?81z2g)%>?dBZQ#3G2A*>%J z)eH%!kb7BYFu;>;iN}yiKOZJ8(#EtX=51fyg8g79pwrwdKxQW4{r6qbj>}q4ePQLS zorTzOv_gpG0OMHl$>5uAQubzOJ)3-Xox0m2rIYAP0B2R`$Ub!=xl&yCxsyF&o(W{w z_!%bZLEdW?f#v}G%^-xk_SK=Kjx=_*r~!!xz&RlCMlI367V!7V$}gLZ9X;G?x6%Nl zf@GCvvI+5yTrUlrh>YT^$hX)Tb_p}7_L=c_A~}`L461Ixph=GtMo!nswXL#KQ7SNG z{rqd!bJ9sT`N7?0>#(K#r0~#vahzt4_Xnw-tWP`Me6$weq1v>t#TxVJ9e!fC@yeoG zScnc-*?q?tOQ`{RmZYVBDK61hQ|u*z1uLnfbal`O{rvQqh+%8D3B)?xR!XFg2}<|0dP)N;&he z4274@_|}dy1MC}I-$rk12d^)XCw7OE)@ZSAQuhX?#huE8J(Jsbd06#H>rfH6|0t1u zoyMb@T~X>;H`*w@90{8u4J6k1|J_`Et&MiU8i8(Xu8P~EEbeR4^y)v`4Gmf^ zGWvJzZsZ>*3^~URsq=LRBb1otk1iv7>9RV(MH_UO z`T-D1TK~O^J`yG($J}*I3Ej_1?RV=96(?nGy#NmLR|a)XrcsT} z{#Y-xwX2oW|P!1Ic0Iri=AriOzm;!%7i@3WBt*bJj+b$zkn5~f1x+UN!A60z)N<5qCQdmhQS zpg9BrB;EqKxU(nTs9aKTU2ot6r5sCyn6;JZ2C@`0xoT)&6wy#h4*WoetG2XS5Q;65 zAtqRwB_e$rLUr};Eb}>RWfZ=BidK;APU$Y}kO)biiJ?P&jze5)n#ZTBg~2#^8rh7i z<^#Yl>7&YDr^)iHNf^N_*M5_Tc_$%_+>S}ZPtsiDgf%4ex`HI;64Z{r4Ua(qJ&a@S zbCX^0zDtOwx${{fxsx{w?XlDeuIO@*Z`TY% zImT{HtnTCqMoAJAvzY`h+M_OUup`QSZjf6Z+j(hZ6hJN{INFr|44P6>S{L9L>$r?SGh z`&gYgVj2;!TX(3dB3prGg4uT*Wl#g*HJ=fZ5-FJ}@qqR>ES%AqSkOAg15U!TFda`z zwJcnQ_(%@B;#64FiBRXYy;wYen|n~;nDf*!olw8AcJ4~Z&}J%lZkB%sQuNi#3MvaO z=vTLqvT&NEvN>_w1IWCxGM2Gt z1HJ(vk07OfyyUslv+`H|u3mvHCcEe`H~VTf9$rnU@`QV`zIePjIM8sLoBE%> ze-^~gQwe+H`W_0+{FK#i>izI#%TM@Ul#i=>XhnE+acN;OhO9Q`H}=H+D_v|a2_C^n zUFh0AAsf=I58X+8LnIyCfrc#50fOa#(rY1hok zpTeg|iNB}&(VKKD7aOg2KWl)&AHZtP{LH4X57X3>J$<>AI|S@n9YX$BG^v5nEv!3? zf7KCk_V#GXn+&I@i1Iuh_%;n0!kMx}G%Plj9dcPIExb-^tChzZDpE;6TeqI+6Zd*sk9^ zywt!hnDO2RaaAh`0Dkw$ym6zI60d0J@PZz=-=%y-$Jgg}pqVsVB}k^^oG4ITh*e1+ z^k)U@38yT*?>%~47cdkk^XM|^C)7d@+`<7q;+al<`_on)Hq1oJ0-z91v#U&LE@=}l zVJ(U8Bvw?f+?D`@Orps%fJ1o4WECU))s}5x52z|9L*jbnuD`{EyPZcX7`;hm+EhdS zn(9MFjtz5cJ~^J10h*`vSsWX%edR!DrIlW)PY@K{OEi~>AWiILt{Sbto2G1JIGXy` z_Fy7OgldrpDPm&sm5~-sqQA@jmY68YA_4zYZ-Dq(&Di7oxZ652$5Vkk9w$qirhM?c z(M>j&IaOBUq+s=3O$EzhJpistQqZEPs6K+Z`p_wXR$e<|jDUJBsP#kX9LnY7aJUH+8Ntf&lp_`fsZc*w6PN*+cB0vD{V7JV#mYlhFAJ)_NLXwHlNi>0!=gsu1JOqgc!4jf%jL)n?eqv~0DI#)7f>p&*jd~DskO6%f-HX= zzgx09o+Z2y+Bl}rj|xSddHAS)SA1ZPA@*J{D5F*2;0I65mkRRqaMuy7Kh68T>Wj2w zJtlOwd_|w7qPm3Zu3SKVOP3dO&X?5!X2}yZKj!b49z&L>t$ahoaoc8thdXbk2Ue<+ z^n2yz8?GZ=SLQXiH}5O#(k3D806}0%tVQ^@$?+3+s)WQc`i+nco2qdM`{iW${ha6r zRdmm*Akj>hmlXt_Y+gdespRXB)EPlJD%T`NouJSuUwSOelICI*X>UMOQ z8ObV>6M?t12zZ>&_9xWwDOkEETRU>+F}QttC#$bK(t&6GuUHO)8UVJ5?y8k!=C2R@JbB0L7voM4Rf>LV;~~KNxx-Zdyy|AC}L3 zALt#h`7M&?*?~QJ6}4-psC%_WwO{BQh7y+W_fjBvcPm=f0e2u|J9J@fO1j;S8{c&W z(SxlM8!lKY3YVL7lJERfDEr~G!UfkU-7=pK6A=qBD;y0J9k3%+nmjAAUl?Sfq4q1%rcsup#`-{FuCm!vKFh7ZpSMZIV7H9qh zh=S18!!37N7bKbR#3TZM4`OsY|K<>D9{nw090Aa6m<1|Dz5YI)3Q~G0N14mF7hujRFT>9VrTET&@Bo9Y8C>A_#e5TR zdfFK+=Env`$Tk`ZE315@QmqQmP9UJ5reMOBWhRq+lcX#OfeO$5G$OZ?n6+Te95$Ae z2aHwrnHC`xR`q`=27XI%W<+*F`Z+sf1j!Zi9HyjQ=M7m)zeTP0=~gbOxx|3g7 zUt~s4Q8u{lOMn;_O!u_{{n#7?r6p2o#{DBS!}cm!POz)8zdJt|EJa6EdHtb!s{(3~=@Ecq!U_~Ch?ME`)(!>wygJy+iaDzHuJXkDqS#AaktmX@?7PKc?_7w80qgUPo67#m z%Jt>PEOA@KY=5(3AJmkCy{x{I<+DZq+7@?GdCj_oe4ReJbnfXVuj3ng=z3iC7}J;) z;mkv_DL+DttfA24IImAF!*TAU<;nDH_NlbW{w@zjmpQOdL>|qg!ni2Q9DOzvvB8x2 zDSfWR8AKm|6J7wa=&g7M=fS*{0uh!;RHW&7*c7STP({ng+uyf<#qil z{81Uo0kYZK#_`1CiqA`%wNj#SQweCaBk2ybO%aLkLK~rnkY~EA9V`x>yOnI*7&sy- zx1_5uE&d18O&G!+ycwJ}upZpzrV$_tqToyQdpREo<-d-XSKZ9KqwZXPhy%(+jK59{ z`%=zYMt0st>QFLIp18}cAq-}Lc&_jIy$Z82fpN-Sl$u?Zm^mE`RJETywi*c<^8+GC%U9X;b-I9Mu9t_^x9;P`OyO!)`|s3UNA+pWDMv_Ofl zK7sMwm0Cn!xKa_2oRp;rc+RuH;Si^;K2xD`QJN(qZp3?XiH1Oun79@n+U2S>V*&E- zH31<(;-7Js14Xt?kCl%}otM|JYbNyy?eLiw#$d%trkE|R6jXme!_+VZgXV^)>W42n ztMv_r7l~7g#qe;R0P+xC0k8hLf$AcL#i~~YBrz*3RW-7o*G_5$4Y{qJ@H`MLbsij= zHb!_sfkGHclX|e9h;nbpQgj2DgV0DPzG!2*ctTsR9`36NP^sF!v!DT04o`|Xbym_d zdFD0SEdFKxT|Qz(Fbjt%4+xUD3ttDNe;CFbtRFJ8mLRHwg+36DS{1Yo3LhKXjsYVL zi($$K;CGf3BApoQZ|xYI=#rtjwY?ea=yehSQ&MfW(xDB*eEAt!;=h{&ZGo%{{pnA6 zq{_An%ReZKOhjE2Y*pxT!ES3-tG1Pk&KxwFjn8&2^O;=mC%O-<8QTN=RUp;s@`V?$7Vxa%6TuN4AP9fFgh`q&rZ=g zRD3x`$XXYf%L16Jm|?(xcWE)cbmjZ*8hfmGPB6n5#yI*ga@ z!L68*40!or_H6IpL-V3ajzY5kQj%xFEru5Dj*<^7`KFS)cmQTU*H2{d8f zWHL0*M`I#540Ol0;_SnzFU4kkXV;6Crd+4P(d@i$OHbnoss8ME9Gs8vt&+5g$=C<< zx-9_LmfB@fP4Fi~1PNCU5K49YSAA6(m=j+U++3mrW%?&i25A9ODZ-dkiC5O8g?N3= zjJX6ZPcGs?qEJkV0gv3l7CHw{GIr#WNO8sWbC1xqYtXP*$1rN9SC@8<=;7#gqL~EW zW_sUz2b=OUD5e`(W1#CBDXzc;717vQo_XwZaG-tZefQzi5Vll{P_P-q*X_CLZ_
Dp&~qT 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 RH5&|Y{LG^L|DHYmzW^9af`0%2 literal 0 HcmV?d00001 From 1b9f5a79a5b9eac8c64826837908ff0cfda47b5f Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 28 May 2024 10:44:06 -0400 Subject: [PATCH 07/16] Software SS: activities (#19292) --- .../18847-software-self-install-activities | 1 + docs/Using Fleet/Audit-logs.md | 14 +++++--- docs/Using Fleet/Understanding-host-vitals.md | 32 +++++++++++++------ server/datastore/mysql/activities.go | 4 ++- server/fleet/activities.go | 28 +++++++++------- server/service/integration_core_test.go | 2 +- server/service/integration_enterprise_test.go | 1 + server/service/orbit.go | 4 ++- 8 files changed, 59 insertions(+), 27 deletions(-) create mode 100644 changes/18847-software-self-install-activities diff --git a/changes/18847-software-self-install-activities b/changes/18847-software-self-install-activities new file mode 100644 index 0000000000..d7c1a8e2f6 --- /dev/null +++ b/changes/18847-software-self-install-activities @@ -0,0 +1 @@ +* Added the `self_install` and `software_package` fields to the `installed_software` activity. diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index add0005ed7..d94e129c5b 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -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 } - ``` diff --git a/docs/Using Fleet/Understanding-host-vitals.md b/docs/Using Fleet/Understanding-host-vitals.md index a265bd01fe..d28985462f 100644 --- a/docs/Using Fleet/Understanding-host-vitals.md +++ b/docs/Using Fleet/Understanding-host-vitals.md @@ -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). @@ -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, 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, darwin, windows - 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, darwin, windows - Query: ```sql diff --git a/server/datastore/mysql/activities.go b/server/datastore/mysql/activities.go index a37ece00d8..536fed054e 100644 --- a/server/datastore/mysql/activities.go +++ b/server/datastore/mysql/activities.go @@ -289,8 +289,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', %s, + 'self_service', si.self_service ) as details FROM host_software_installs hsi diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 4b05ad1951..e7c06a0514 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -1422,6 +1422,8 @@ type ActivityTypeInstalledSoftware struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` SoftwareTitle string `json:"software_title"` + SoftwarePackage string `json:"software_package"` + SelfService bool `json:"self_service"` InstallUUID string `json:"install_uuid"` Status string `json:"status"` } @@ -1440,11 +1442,15 @@ func (a ActivityTypeInstalledSoftware) Documentation() (activity, details, detai - "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.`, `{ "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" }` @@ -1467,22 +1473,22 @@ func (a ActivityTypeAddedSoftware) Documentation() (string, string, string) { - "software_title": Name of the software. - "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.`, - `{ +- "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.`, `{ "software_title": "Falcon.app", "software_package": "FalconSensor-6.44.pkg", "team_name": "Workstations", - "team_id": 123 -} -` + "team_id": 123, + "self_service": true +}` } type ActivityTypeDeletedSoftware struct { - SelfService bool `json:"self_service"` SoftwareTitle string `json:"software_title"` SoftwarePackage string `json:"software_package"` TeamName *string `json:"team_name"` TeamID *uint `json:"team_id"` + SelfService bool `json:"self_service"` } func (a ActivityTypeDeletedSoftware) ActivityName() string { @@ -1494,14 +1500,14 @@ func (a ActivityTypeDeletedSoftware) Documentation() (string, string, string) { - "software_title": Name of the software. - "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.`, - `{ +- "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.`, `{ "software_title": "Falcon.app", "software_package": "FalconSensor-6.44.pkg", "team_name": "Workstations", - "team_id": 123 -} -` + "team_id": 123, + "self_service": true +}` } // LogRoleChangeActivities logs activities for each role change, globally and one for each change in teams. diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 6d6c24fa00..0cb2f6fe00 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -6602,7 +6602,7 @@ func (s *integrationTestSuite) TestListSoftwareAndSoftwareDetails() { // create a bunch of software sws := make([]fleet.Software, 20) for i := range sws { - sw := fleet.Software{Name: fmt.Sprintf("sw%02d", i), Version: "0.0." + strconv.Itoa(i), Source: "apps"} + sw := fleet.Software{Name: fmt.Sprintf("sw%02d", i), Version: fmt.Sprintf("0.0.%02d", i), Source: "apps"} if i%2 == 0 { sw.Source = "chrome_extensions" sw.Browser = "chrome" diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 3ec0227842..9cfad07f45 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -9737,6 +9737,7 @@ func (s *integrationEnterpriseTestSuite) TestHostSoftwareInstallResult() { HostID: host.ID, HostDisplayName: host.DisplayName(), SoftwareTitle: payload.Title, + SoftwarePackage: payload.Filename, InstallUUID: installUUIDs[0], Status: string(fleet.SoftwareInstallerFailed), } diff --git a/server/service/orbit.go b/server/service/orbit.go index 3581957078..dc83ba89e3 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -910,7 +910,7 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f } var user *fleet.User - if hsi.UserID != nil { + if hsi.UserID != nil && !hsi.SelfService { user, err = svc.ds.UserByID(ctx, *hsi.UserID) if err != nil { return ctxerr.Wrap(ctx, err, "get host software installation user") @@ -924,8 +924,10 @@ func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *f HostID: host.ID, HostDisplayName: host.DisplayName(), SoftwareTitle: hsi.SoftwareTitle, + SoftwarePackage: hsi.SoftwarePackage, InstallUUID: result.InstallUUID, Status: string(status), + SelfService: hsi.SelfService, }, ); err != nil { return ctxerr.Wrap(ctx, err, "create activity for software installation") From 62954b1c830c03bf9c70d0a58ffa08193c1185a0 Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Wed, 29 May 2024 11:50:39 +0100 Subject: [PATCH 08/16] Add UI for self service activities (#19305) relates to #18847 This adds the global and host activities for self service activities. This also updates the Upcoming host activities to follow the same pattern as the Host Past activities. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality --- ...e-18847-add-ui-activities-for-self-service | 1 + frontend/interfaces/activity.ts | 17 ++- .../ActivityItem/ActivityItem.tests.tsx | 44 ++++--- .../ActivityItem/ActivityItem.tsx | 46 ++++--- .../HostDetailsPage/HostDetailsPage.tsx | 12 +- .../hosts/details/cards/Activity/Activity.tsx | 16 +-- .../details/cards/Activity/ActivityConfig.tsx | 20 ++- .../InstalledSoftwareActivityItem.tsx | 5 +- .../RanScriptActivityItem.tsx | 8 +- .../PastActivityFeed/PastActivityFeed.tsx | 9 +- .../UpcomingActivity/UpcomingActivity.tsx | 116 ------------------ .../Activity/UpcomingActivity/_styles.scss | 65 ---------- .../cards/Activity/UpcomingActivity/index.ts | 1 - .../UpcomingActivityFeed.tsx | 27 ++-- frontend/services/entities/activities.ts | 21 +++- 15 files changed, 149 insertions(+), 259 deletions(-) create mode 100644 changes/issue-18847-add-ui-activities-for-self-service delete mode 100644 frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx delete mode 100644 frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss delete mode 100644 frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts diff --git a/changes/issue-18847-add-ui-activities-for-self-service b/changes/issue-18847-add-ui-activities-for-self-service new file mode 100644 index 0000000000..d3c82f980f --- /dev/null +++ b/changes/issue-18847-add-ui-activities-for-self-service @@ -0,0 +1 @@ +- add UI for the global and host activities for self-service software installation diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index d61b327676..06732e90ef 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -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 & { - type: IHostActivityType; +export type IHostPastActivity = Omit & { + type: IHostPastActivityType; + details: IActivityDetails; +}; + +export type IHostUpcomingActivity = Omit & { + type: IHostUpcomingActivityType; details: IActivityDetails; }; @@ -142,4 +152,5 @@ export interface IActivityDetails { software_package?: string; status?: string; install_uuid?: string; + self_service?: boolean; } diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx index e8bff33d98..830d485425 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx @@ -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(); + 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(); + expect(screen.getByText("An end user")).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index 7378d190ee..c3a2759329 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -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 {activity.actor_email} ; + const DEFAULT_ACTOR_DISPLAY = {activity.actor_full_name} ; + + switch (activity.type) { + case ActivityType.UserLoggedIn: + return {activity.actor_email} ; + case ActivityType.UserChangedGlobalRole: + case ActivityType.UserChangedTeamRole: + return activity.actor_id === activity.details?.user_id ? ( + {activity.details?.user_email} + ) : ( + DEFAULT_ACTOR_DISPLAY + ); + case ActivityType.InstalledSoftware: + return activity.details?.self_service ? ( + An end user + ) : ( + 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 {activity.details?.user_email} ; - } - return {activity.actor_full_name} ; }; + return (
{ position="top-start" tipContent={ <> - Upcoming activities will run as listed. Failure of one activity won’t - cancel other activities. + Upcoming activities will run as listed. Failure of one activity + won't cancel other activities.

Currently, only scripts are guaranteed to run in order. @@ -48,7 +48,7 @@ const UpcomingTooltip = () => { interface IActivityProps { activeTab: "past" | "upcoming"; - activities?: IHostActivitiesResponse | IUpcomingActivitiesResponse; + activities?: IHostPastActivitiesResponse | IHostUpcomingActivitiesResponse; isLoading?: boolean; isError?: boolean; upcomingCount: number; @@ -101,7 +101,7 @@ const Activity = ({ | React.FC > = { @@ -34,3 +37,12 @@ export const pastActivityComponentMap: Record< [ActivityType.UnlockedHost]: UnlockedHostActivityItem, [ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem, }; + +export const upcomingActivityComponentMap: Record< + IHostUpcomingActivityType, + | React.FC + | React.FC +> = { + [ActivityType.RanScript]: RanScriptActivityItem, + [ActivityType.InstalledSoftware]: InstalledSoftwareActivityItem, +}; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx index fd0d7d3fa6..dfa6bcb2ab 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/InstalledSoftwareActivityItem/InstalledSoftwareActivityItem.tsx @@ -31,12 +31,13 @@ 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 ? "An end user" : actorName; return ( - {actorName} {getSoftwareInstallStatusPredicate(status)}{" "} + {actorDisplayName} {getSoftwareInstallStatusPredicate(status)}{" "} {title} software on this host.{" "} diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx index 94b1a8dc7c..e0f26d0a8a 100644 --- a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx @@ -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 ( {activity.actor_full_name} <> {" "} - ran {formatScriptNameForActivityItem(activity.details?.script_name)} on - this host.{" "} + {ranScriptPrefix}{" "} + {formatScriptNameForActivityItem(activity.details?.script_name)} on this + host.{" "} diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx index 97f0346141..5e58a50f4c 100644 --- a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx +++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx @@ -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 (
- {activitiesList.map((activity: IHostActivity) => { + {activitiesList.map((activity: IHostPastActivity) => { const ActivityItemComponent = pastActivityComponentMap[activity.type]; return ( diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx deleted file mode 100644 index 8a202e4de9..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/UpcomingActivity.tsx +++ /dev/null @@ -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 ? ( - <> - {details.software_title}{" "} - - ) : ( - "" - )} - 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 ( -
- -
-
- - {activity.actor_full_name} {formatPredicate(activity)} on - this host.{" "} - - -
- - {formatDistanceToNowStrict(activityCreatedAt, { - addSuffix: true, - })} - - - {internationalTimeFormat(activityCreatedAt)} - -
-
-
-
- ); -}; - -export default UpcomingActivity; diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss deleted file mode 100644 index afaa538a71..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/_styles.scss +++ /dev/null @@ -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; - } - } -} diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts b/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts deleted file mode 100644 index 413a03e29a..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivity/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./UpcomingActivity"; diff --git a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx index 32064a42ba..c9c12696f2 100644 --- a/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx +++ b/frontend/pages/hosts/details/cards/Activity/UpcomingActivityFeed/UpcomingActivityFeed.tsx @@ -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 (
- {activitiesList.map((activity: IHostActivity) => ( - - ))} + {activitiesList.map((activity: IHostUpcomingActivity) => { + const ActivityItemComponent = + upcomingActivityComponentMap[activity.type]; + return ( + + ); + })}
@@ -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 = ({ <> {name} - {hasPackage && } + {hasPackage && ( + + )} } /> diff --git a/frontend/components/TooltipWrapper/TooltipWrapper.tsx b/frontend/components/TooltipWrapper/TooltipWrapper.tsx index aa81f28c50..1fee1e01b1 100644 --- a/frontend/components/TooltipWrapper/TooltipWrapper.tsx +++ b/frontend/components/TooltipWrapper/TooltipWrapper.tsx @@ -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 = ({
{children}
- - {tipContent} - + {!disableTooltip && ( + + {tipContent} + + )} ); }; diff --git a/frontend/components/icons/InstallSelfService.tsx b/frontend/components/icons/InstallSelfService.tsx new file mode 100644 index 0000000000..05f9339480 --- /dev/null +++ b/frontend/components/icons/InstallSelfService.tsx @@ -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 ( + + + + + + + + + + + ); +}; + +export default InstallSelfService; diff --git a/frontend/components/icons/index.ts b/frontend/components/icons/index.ts index 0a19e3b1dc..3b573cf9ac 100644 --- a/frontend/components/icons/index.ts +++ b/frontend/components/icons/index.ts @@ -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, }; diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 29afed9537..910b8175e5 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -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; diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts index 68923d47b7..bb626b1c2a 100644 --- a/frontend/interfaces/software.ts +++ b/frontend/interfaces/software.ts @@ -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 { + software_package: string | null; + self_service: boolean; +} + +export interface ISoftwareTitleWithPackageDetail + extends Omit { + 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; + }; +} diff --git a/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/_styles.scss b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/_styles.scss index 89744cf6cc..c37c7cf654 100644 --- a/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/_styles.scss +++ b/frontend/pages/ManageControlsPage/OSSettings/ProfileStatusAggregate/_styles.scss @@ -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; diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx index d67255a6c2..f9707579d5 100644 --- a/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx +++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/SetupAssistant/components/SetupAssistantPreview/SetupAssistantPreview.tsx @@ -8,12 +8,7 @@ const baseClass = "setup-assistant-preview"; const SetupAssistantPreview = () => { return ( - +

End user experience

After the end user continues past the Remote Management screen, diff --git a/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx index 37a299bf0b..257b09365c 100644 --- a/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareOSDetailsPage/SoftwareOSDetailsPage.tsx @@ -183,7 +183,7 @@ const SoftwareOSDetailsPage = ({ name={osVersionDetails.platform} /> diff --git a/frontend/pages/SoftwarePage/SoftwarePage.tsx b/frontend/pages/SoftwarePage/SoftwarePage.tsx index 0916ba8c97..7dc5629f58 100644 --- a/frontend/pages/SoftwarePage/SoftwarePage.tsx +++ b/frontend/pages/SoftwarePage/SoftwarePage.tsx @@ -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 diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx index cea4bfc05d..665ea5ed6d 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwarePackageCard/SoftwarePackageCard.tsx @@ -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(null); + const isTruncated = useTruncatedElement(titleRef); + + return ( + +

+ {name} +
+ + ); +}; + 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 ( +
+ +
+ ); +}; + 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 ( - +
{/* TODO: main-info could be a seperate component as its reused on a couple pages already. Come back and pull this into a component */}
- - {softwarePackage.name} - + Version {softwarePackage.version} •
- {showActions && ( -
- - {/* TODO: make a component for download icons */} - - -
- )} +
+ {true && ( +
+ + Self-service +
+ )} + {showActions && ( + + )} +
{showAdvancedOptionsModal && ( .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; } } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index 772d189ece..10d5b41fd0 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -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 = ({ /> )} diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx index fa52e1d724..832ebdf338 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTable.tsx @@ -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 { @@ -152,7 +160,10 @@ const SoftwareTable = ({ [determineQueryParamChange, generateNewQueryParams, router, currentPath] ); - let tableData: ISoftwareTitle[] | ISoftwareVersion[] | undefined; + let tableData: + | ISoftwareTitleWithPackageName[] + | ISoftwareVersion[] + | undefined; let generateTableConfig: ITableConfigGenerator; if (data === undefined) { @@ -223,28 +234,23 @@ const SoftwareTable = ({ ); }; - const handleVulnFilterDropdownChange = ( + const handleCustomFilterDropdownChange = ( value: ISoftwareDropdownFilterVal ) => { - const queryParams: Record = { + 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), }) ); }; @@ -302,7 +308,7 @@ const SoftwareTable = ({ className={`${baseClass}__vuln_dropdown`} options={options} searchable={false} - onChange={handleVulnFilterDropdownChange} + onChange={handleCustomFilterDropdownChange} tableFilterDropdown />
@@ -345,6 +351,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)" diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx index 9cf8e41e24..00839a7748 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/SoftwareTitlesTableConfig.tsx @@ -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; -type ITableStringCellProps = IStringCellProps; -type IVersionsCellProps = CellProps; +type ISoftwareTitlesTableConfig = Column; +type ITableStringCellProps = IStringCellProps; +type IVersionsCellProps = CellProps< + ISoftwareTitleWithPackageName, + ISoftwareTitleWithPackageName["versions"] +>; type IVulnerabilitiesCellProps = IVersionsCellProps; type IHostCountCellProps = CellProps< - ISoftwareTitle, - ISoftwareTitle["hosts_count"] + ISoftwareTitleWithPackageName, + ISoftwareTitleWithPackageName["hosts_count"] >; -type IViewAllHostsLinkProps = CellProps; +type IViewAllHostsLinkProps = CellProps; -type ITableHeaderProps = IHeaderProps; +type ITableHeaderProps = IHeaderProps; 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} /> ); }, diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts index 59fa04a46c..5cdc0fcde9 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTable/helpers.ts @@ -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"; + } +}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx index b594420270..f7a537cf0a 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitles/SoftwareTitles.tsx @@ -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; @@ -60,25 +57,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, @@ -89,10 +67,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, @@ -109,7 +99,7 @@ const SoftwareTitles = ({ ISoftwareVersionsResponse, Error, ISoftwareVersionsResponse, - ISoftwareVersionsQueryKey[] + [ISoftwareVersionsQueryKey] >( [ { @@ -123,7 +113,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, diff --git a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx index 861a13ea95..328713f78c 100644 --- a/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVersionDetailsPage/SoftwareVersionDetailsPage.tsx @@ -152,7 +152,7 @@ const SoftwareVersionDetailsPage = ({ source={softwareVersion.source} /> diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx index af8cb9e9bd..07028e7f75 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnOSVersions/SoftwareVulnOSVersions.tsx @@ -68,7 +68,7 @@ const SoftwareVulnOSVersions = ({ }; return ( - +

Vulnerable OS

{renderVulnerableOSTable()}
diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx index a1027bfb01..29ed8561d1 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSoftwareVersions/SoftwareVulnSoftwareVersions.tsx @@ -68,7 +68,7 @@ const SoftwareVulnSoftwareVersions = ({ ); }; return ( - +

Vulnerable software

{renderVulnerableSoftwareTable()}
diff --git a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx index 7b55f67494..a2ce0c221c 100644 --- a/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx +++ b/frontend/pages/SoftwarePage/SoftwareVulnerabilityDetailsPage/SoftwareVulnSummary/SoftwareVulnSummary.tsx @@ -38,7 +38,7 @@ const SoftwareVulnSummary = ({ } = vuln; return ( - +

{cve}

diff --git a/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx b/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx index 6f7fdfc001..d96d9f09b9 100644 --- a/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx +++ b/frontend/pages/SoftwarePage/components/AddSoftwareForm/AddSoftwareForm.tsx @@ -1,12 +1,15 @@ -import React, { useState } from "react"; +import React, { useContext, useState } from "react"; +import { NotificationContext } from "context/notification"; import getInstallScript from "utilities/software_install_scripts"; -import Spinner from "components/Spinner"; import Button from "components/buttons/Button"; -import FileUploader from "components/FileUploader"; +import Checkbox from "components/forms/fields/Checkbox"; import Graphic from "components/Graphic"; import Editor from "components/Editor"; +import FileUploader from "components/FileUploader"; +import Spinner from "components/Spinner"; +import TooltipWrapper from "components/TooltipWrapper"; import AddSoftwareAdvancedOptions from "../AddSoftwareAdvancedOptions"; @@ -50,6 +53,7 @@ export interface IAddSoftwareFormData { installScript: string; preInstallCondition?: string; postInstallScript?: string; + selfService: boolean; } export interface IFormValidation { @@ -57,6 +61,7 @@ export interface IFormValidation { software: { isValid: boolean }; preInstallCondition?: { isValid: boolean; message?: string }; postInstallScript?: { isValid: boolean; message?: string }; + selfService?: { isValid: boolean }; } interface IAddSoftwareFormProps { @@ -70,6 +75,8 @@ const AddSoftwareForm = ({ onCancel, onSubmit, }: IAddSoftwareFormProps) => { + const { renderFlash } = useContext(NotificationContext); + const [showPreInstallCondition, setShowPreInstallCondition] = useState(false); const [showPostInstallScript, setShowPostInstallScript] = useState(false); const [formData, setFormData] = useState({ @@ -77,6 +84,7 @@ const AddSoftwareForm = ({ installScript: "", preInstallCondition: undefined, postInstallScript: undefined, + selfService: false, }); const [formValidation, setFormValidation] = useState({ isValid: false, @@ -86,10 +94,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( @@ -153,6 +170,18 @@ const AddSoftwareForm = ({ ); }; + const onToggleSelfServiceCheckbox = (value: boolean) => { + const newData = { ...formData, selfService: value }; + setFormData(newData); + setFormValidation( + generateFormValidation( + newData, + showPreInstallCondition, + showPostInstallScript + ) + ); + }; + const isSubmitDisabled = !formValidation.isValid; return ( @@ -194,6 +223,21 @@ const AddSoftwareForm = ({ } /> )} + + + End users can install from{" "} + Fleet Desktop {">"} Self-service. + + } + > + Self-service + + - {formData.software?.name} successfully added. Go to Host - details page to install software. + {formData.software?.name} 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)); diff --git a/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx b/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx index 6c1c62a60b..94187808c7 100644 --- a/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx +++ b/frontend/pages/SoftwarePage/components/DetailsNoHosts/DetailsNoHosts.tsx @@ -11,7 +11,7 @@ interface IDetailsNoHosts { const DetailsNoHosts = ({ header, details }: IDetailsNoHosts) => { return ( - +

{header}

{details}

diff --git a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss index 3a273a7046..5006d85624 100644 --- a/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss +++ b/frontend/pages/SoftwarePage/components/SoftwareDetailsSummary/_styles.scss @@ -10,8 +10,6 @@ .software-icon { width: 96px; height: 96px; - border: 1px solid $ui-fleet-black-10; - border-radius: 8px; } &__info { diff --git a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss index 75a962198d..328a564a1e 100644 --- a/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss +++ b/frontend/pages/SoftwarePage/components/icons/SoftwareIcon/_styles.scss @@ -1,3 +1,5 @@ .software-icon { flex-shrink: 0; + border: 1px solid $ui-fleet-black-10; + border-radius: 8px; } diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts index 4e7e910a30..7adf14e68c 100644 --- a/frontend/pages/SoftwarePage/components/icons/index.ts +++ b/frontend/pages/SoftwarePage/components/icons/index.ts @@ -73,6 +73,7 @@ export const SOFTWARE_SOURCE_TO_ICON_MAP = { export const SOFTWARE_ICON_SIZES: Record = { medium: "24", + meduim_large: "64", // TODO: rename this to large and update large to xlarge large: "96", } as const; diff --git a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx index 74b1723dcc..114b537d3b 100644 --- a/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx +++ b/frontend/pages/hosts/ManageHostsPage/HostsPageConfig.tsx @@ -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", diff --git a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx index e3f0431394..a23fd833f0 100644 --- a/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx +++ b/frontend/pages/hosts/details/DeviceUserPage/DeviceUserPage.tsx @@ -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(null); const [showRefetchSpinner, setShowRefetchSpinner] = useState(false); const [orgLogoURL, setOrgLogoURL] = useState(""); + const [orgContactURL, setOrgContactURL] = useState(""); const [selectedPolicy, setSelectedPolicy] = useState( 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 = ({ > Details + {isPremiumTier && Self-service} {isSoftwareEnabled && Software} {isPremiumTier && ( @@ -413,6 +436,18 @@ const DeviceUserPage = ({ munki={deviceMacAdminsData?.munki} /> + {isPremiumTier && ( + + + + )} {isSoftwareEnabled && ( An end user
+ ) : ( + {actorName} + ); return ( - {actorDisplayName} {getSoftwareInstallStatusPredicate(status)}{" "} + <>{actorDisplayName} {getSoftwareInstallStatusPredicate(status)}{" "} {title} software on this host.{" "} diff --git a/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx b/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx index d91cd53952..98a25c903d 100644 --- a/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx +++ b/frontend/pages/hosts/details/cards/AgentOptions/AgentOptions.tsx @@ -53,7 +53,7 @@ const AgentOptions = ({ return ( { return ( diff --git a/frontend/pages/hosts/details/cards/Packs/Packs.tsx b/frontend/pages/hosts/details/cards/Packs/Packs.tsx index cdc2558247..4654f935e0 100644 --- a/frontend/pages/hosts/details/cards/Packs/Packs.tsx +++ b/frontend/pages/hosts/details/cards/Packs/Packs.tsx @@ -72,7 +72,7 @@ const Packs = ({ packsState, isLoading }: IPacksProps): JSX.Element => { <> ) : (

Software

diff --git a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx index 9b73075d9d..1e8cd5fbb8 100644 --- a/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx +++ b/frontend/pages/hosts/details/cards/Software/HostSoftwareTableConfig.tsx @@ -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 ( - - ); + Cell: ({ row: { original } }: IInstalledStatusCellProps) => { + return ; }, }, { diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx index 2d7112566e..e1f64c857d 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/InstallStatusCell.tsx @@ -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 = { +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 = { failed: { iconName: "error", displayText: "Failed", - tooltip: (_, lastInstall) => ( + tooltip: ({ lastInstalledAt: lastInstall }) => ( <> Fleet failed to install software ({dateAgo(lastInstall as string)} ago). Select Actions > Software details to see more. @@ -48,37 +59,47 @@ const CELL_DISPLAY_OPTIONS: Record = { avaiableForInstall: { iconName: "install", displayText: "Available for install", - tooltip: (softwareName) => ( + tooltip: ({ softwareName }) => ( <> - {softwareName} can be installed on the host. Select{" "} - Actions > Install to install. + {softwareName ? {softwareName} : "Software"} can be installed on + the host. Select Actions {">"} Install to install. + + ), + }, + selfService: { + iconName: "install-self-service", + displayText: "Self-service", + tooltip: ({ softwareName }) => ( + <> + {softwareName ? {softwareName} : "Software"} can be installed on + the host. End users can install from{" "} + Fleet Desktop {">"} Self-service. ), }, }; -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 ; } - 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} > - + {" "} + {displayConfig.displayText}
- {displayConfig.tooltip(packageToInstall, installedAt)} + {displayConfig.tooltip({ + softwareName, + lastInstalledAt, + })} - {displayConfig.displayText}
); }; diff --git a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss index 7384269a1e..290e151cc0 100644 --- a/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss +++ b/frontend/pages/hosts/details/cards/Software/InstallStatusCell/_styles.scss @@ -5,6 +5,11 @@ gap: $pad-small; } + &__status-with-tooltip { + display: flex; + gap: $pad-small; + } + &__status-tooltip { text-align: center; } diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx new file mode 100644 index 0000000000..f83f9d3b0d --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfService.tsx @@ -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; + 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 ( + +
Self-service
+
+ Install organization-approved apps provided by your IT department.{" "} + {contactUrl && ( + + If you need help,{" "} + + + )} +
+ {isLoading ? ( + + ) : ( + <> + {isError && } + {!isError && ( +
+ {isEmpty ? ( + + ) : ( + <> +
+ {data.count} 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 ( + + ); + })} +
+ + + )} +
+ )} + + )} +
+ ); +}; + +export default SoftwareSelfService; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx new file mode 100644 index 0000000000..d04e235e66 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/SelfServiceItem.tsx @@ -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 = { + 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{" "} + Retry to install again, or contact your IT department. + + ), + }, +}; + +interface IInstallerInfoProps { + software: IDeviceSoftware; +} + +const InstallerInfo = ({ software }: IInstallerInfoProps) => { + const { name, source, package: installerPackage } = software; + return ( +
+
+ +
+
+
+ {name || installerPackage?.name} +
+
+ {installerPackage?.version || ""} +
+
+
+ ); +}; + +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 ( +
+
+ + {displayConfig.displayText} +
+ + + {displayConfig.tooltip({ + lastInstalledAt: last_install?.installed_at, + })} + + +
+ ); +}; + +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 ( +
+
+ +
+
+ {(displayStatus === "failed" || displayStatus === null) && ( + + )} +
+
+ ); +}; + +interface ISelfServiceItemProps { + deviceToken: string; + software: IDeviceSoftware; + onInstall: () => void; +} + +const SelfServiceItem = ({ + deviceToken, + software, + onInstall, +}: ISelfServiceItemProps) => { + return ( + +
+ + +
+
+ ); +}; + +export default SelfServiceItem; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss new file mode 100644 index 0000000000..56d357f798 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/_styles.scss @@ -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; + } + } +} diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/index.ts b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/index.ts new file mode 100644 index 0000000000..eebc870793 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/SelfServiceItem/index.ts @@ -0,0 +1 @@ +export { default } from "./SelfServiceItem"; diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss b/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss new file mode 100644 index 0000000000..484312533c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/_styles.scss @@ -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; + } +} diff --git a/frontend/pages/hosts/details/cards/Software/SelfService/index.ts b/frontend/pages/hosts/details/cards/Software/SelfService/index.ts new file mode 100644 index 0000000000..8ee96ff078 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Software/SelfService/index.ts @@ -0,0 +1 @@ +export { default } from "./SelfService"; diff --git a/frontend/pages/hosts/details/cards/Users/Users.tsx b/frontend/pages/hosts/details/cards/Users/Users.tsx index fc738949ee..ed7c01ad56 100644 --- a/frontend/pages/hosts/details/cards/Users/Users.tsx +++ b/frontend/pages/hosts/details/cards/Users/Users.tsx @@ -31,7 +31,7 @@ const Users = ({ if (!hostUsersEnabled) { return ( + diff --git a/frontend/router/paths.ts b/frontend/router/paths.ts index b6a9cb3684..b7c3bd3598 100644 --- a/frontend/router/paths.ts +++ b/frontend/router/paths.ts @@ -136,6 +136,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`; }, diff --git a/frontend/services/entities/device_user.ts b/frontend/services/entities/device_user.ts index d0de571946..33955a419d 100644 --- a/frontend/services/entities/device_user.ts +++ b/frontend/services/entities/device_user.ts @@ -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); + }, }; diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index c5c8be0f44..ac1f71673f 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -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; } diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts index 40be25ddc5..3b4067dd7d 100644 --- a/frontend/services/entities/software.ts +++ b/frontend/services/entities/software.ts @@ -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( - 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 => { + }: Omit< + ISoftwareApiParams, + "availableForInstall" | "selfService" + >): Promise => { 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); diff --git a/frontend/styles/var/_global.scss b/frontend/styles/var/_global.scss index 1032bacc12..74e758e2db 100644 --- a/frontend/styles/var/_global.scss +++ b/frontend/styles/var/_global.scss @@ -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; diff --git a/frontend/styles/var/mixins.scss b/frontend/styles/var/mixins.scss index 16fa29b45d..df8714f0ce 100644 --- a/frontend/styles/var/mixins.scss +++ b/frontend/styles/var/mixins.scss @@ -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 { diff --git a/frontend/utilities/date_format/index.ts b/frontend/utilities/date_format/index.ts index 53de0b5498..329d977dba 100644 --- a/frontend/utilities/date_format/index.ts +++ b/frontend/utilities/date_format/index.ts @@ -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`; }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 0c4659dcf1..a0e26f905c 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -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`; }, diff --git a/frontend/utilities/helpers.tsx b/frontend/utilities/helpers.tsx index 3c4d436c63..23eca08c14 100644 --- a/frontend/utilities/helpers.tsx +++ b/frontend/utilities/helpers.tsx @@ -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"; @@ -825,7 +825,7 @@ interface ILocationParams { pathPrefix?: string; routeTemplate?: string; routeParams?: { [key: string]: string }; - queryParams?: { [key: string]: string | number | undefined }; + queryParams?: QueryParams; } type RouteParams = Record; diff --git a/frontend/utilities/url/index.ts b/frontend/utilities/url/index.ts index 5171d25ac1..b8f274ee17 100644 --- a/frontend/utilities/url/index.ts +++ b/frontend/utilities/url/index.ts @@ -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 = keyof T extends string + ? { + [K in keyof T]: QueryValues; + } + : never; + +export const convertParamsToSnakeCase = >( + params: T +) => { + return reduce( + params, + (result, val, key) => { + result[snakeCase(key)] = val; + return result; + }, + {} + ); +}; From 9afbd0e02f7da80a9ab1609a6dac010493bcad47 Mon Sep 17 00:00:00 2001 From: gillespi314 <73313222+gillespi314@users.noreply.github.com> Date: Fri, 31 May 2024 17:17:37 -0500 Subject: [PATCH 15/16] Update migrations --- ...iceBool.go => 20240521143024_SoftwareSelfServiceBool.go} | 6 +++--- ...st.go => 20240521143024_SoftwareSelfServiceBool_test.go} | 2 +- server/datastore/mysql/schema.sql | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename server/datastore/mysql/migrations/tables/{20240531000000_SoftwareSelfServiceBool.go => 20240521143024_SoftwareSelfServiceBool.go} (76%) rename server/datastore/mysql/migrations/tables/{20240531000000_SoftwareSelfServiceBool_test.go => 20240521143024_SoftwareSelfServiceBool_test.go} (96%) diff --git a/server/datastore/mysql/migrations/tables/20240531000000_SoftwareSelfServiceBool.go b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool.go similarity index 76% rename from server/datastore/mysql/migrations/tables/20240531000000_SoftwareSelfServiceBool.go rename to server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool.go index 4a5951ebde..1d27717752 100644 --- a/server/datastore/mysql/migrations/tables/20240531000000_SoftwareSelfServiceBool.go +++ b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool.go @@ -6,10 +6,10 @@ import ( ) func init() { - MigrationClient.AddMigration(Up_20240531000000, Down_20240531000000) + MigrationClient.AddMigration(Up_20240521143024, Down_20240521143024) } -func Up_20240531000000(tx *sql.Tx) error { +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) @@ -23,6 +23,6 @@ func Up_20240531000000(tx *sql.Tx) error { return nil } -func Down_20240531000000(tx *sql.Tx) error { +func Down_20240521143024(tx *sql.Tx) error { return nil } diff --git a/server/datastore/mysql/migrations/tables/20240531000000_SoftwareSelfServiceBool_test.go b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool_test.go similarity index 96% rename from server/datastore/mysql/migrations/tables/20240531000000_SoftwareSelfServiceBool_test.go rename to server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool_test.go index 73c51bf2d3..e39f899290 100644 --- a/server/datastore/mysql/migrations/tables/20240531000000_SoftwareSelfServiceBool_test.go +++ b/server/datastore/mysql/migrations/tables/20240521143024_SoftwareSelfServiceBool_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestUp_220240531000000(t *testing.T) { +func TestUp_20240521143024(t *testing.T) { db := applyUpToPrev(t) // diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index c4d29ce7cd..0d76506734 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -927,7 +927,7 @@ CREATE TABLE `migration_status_tables` ( UNIQUE KEY `id` (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=268 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240531000000,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'),(254,20240302111134,1,'2020-01-01 01:01:01'),(255,20240312103753,1,'2020-01-01 01:01:01'),(256,20240313143416,1,'2020-01-01 01:01:01'),(257,20240314085226,1,'2020-01-01 01:01:01'),(258,20240314151747,1,'2020-01-01 01:01:01'),(259,20240320145650,1,'2020-01-01 01:01:01'),(260,20240327115530,1,'2020-01-01 01:01:01'),(261,20240327115617,1,'2020-01-01 01:01:01'),(262,20240408085837,1,'2020-01-01 01:01:01'),(263,20240415104633,1,'2020-01-01 01:01:01'),(264,20240430111727,1,'2020-01-01 01:01:01'),(265,20240515200020,1,'2020-01-01 01:01:01'),(266,20240521143023,1,'2020-01-01 01:01:01'),(267,20240521143024,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( From 31957f88cc70ec619a110ee83c52e97010d63a1e Mon Sep 17 00:00:00 2001 From: Gabriel Hernandez Date: Mon, 3 Jun 2024 10:11:56 +0100 Subject: [PATCH 16/16] enable TestVulnerabilityDataSteam test --- cmd/fleetctl/vulnerability_data_stream_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/fleetctl/vulnerability_data_stream_test.go b/cmd/fleetctl/vulnerability_data_stream_test.go index 4693db8d71..0e61949eea 100644 --- a/cmd/fleetctl/vulnerability_data_stream_test.go +++ b/cmd/fleetctl/vulnerability_data_stream_test.go @@ -12,8 +12,6 @@ import ( ) func TestVulnerabilityDataStream(t *testing.T) { - t.Skip("TODO: re-enable before merging feature branch to main!") - nettest.Run(t) runAppCheckErr(t, []string{"vulnerability-data-stream"}, "No directory provided")