├── .gitignore ├── Jamf PowerBI Basic Installation Instructions for PowerBI Desktop.pdf ├── JamfPro.mez ├── JamfPro ├── .vscode │ ├── settings.json │ └── tasks.json ├── ComputerMappings.pqm ├── JamfPro.pq ├── JamfPro.proj ├── JamfPro.query.pq ├── JamfPro16.png ├── JamfPro20.png ├── JamfPro24.png ├── JamfPro32.png ├── JamfPro40.png ├── JamfPro48.png ├── JamfPro64.png ├── MobileDeviceMappings.pqm └── resources.resx └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .DS_Store 3 | bin -------------------------------------------------------------------------------- /Jamf PowerBI Basic Installation Instructions for PowerBI Desktop.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/powerbi/46bf1fd74bb3548e52f61454d5a079c7244f00c8/Jamf PowerBI Basic Installation Instructions for PowerBI Desktop.pdf -------------------------------------------------------------------------------- /JamfPro.mez: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/powerbi/46bf1fd74bb3548e52f61454d5a079c7244f00c8/JamfPro.mez -------------------------------------------------------------------------------- /JamfPro/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "powerquery.general.mode": "SDK", 3 | "powerquery.sdk.defaultExtension": "${workspaceFolder}\\bin\\AnyCPU\\Debug\\${workspaceFolderBasename}.mez", 4 | "powerquery.sdk.defaultQueryFile": "${workspaceFolder}\\${workspaceFolderBasename}.query.pq" 5 | } -------------------------------------------------------------------------------- /JamfPro/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "powerquery", 6 | "operation": "compile", 7 | "additionalArgs": [], 8 | "group": "build", 9 | "problemMatcher": [], 10 | "label": "build" 11 | }, 12 | { 13 | "type": "shell", 14 | "command": "cp bin\\AnyCPU\\Debug\\JamfPro.mez ..\\JamfPro.mez", 15 | "dependsOn": [ 16 | "build" 17 | ], 18 | "label": "build and copy mez artifact to parent directory", 19 | "problemMatcher": [], 20 | "group": { 21 | "kind": "build", 22 | "isDefault": true 23 | } 24 | } 25 | ] 26 | } -------------------------------------------------------------------------------- /JamfPro/ComputerMappings.pqm: -------------------------------------------------------------------------------- 1 | [ 2 | ApiFields = (mappings as list) as list => List.Transform(mappings, each _[api]), 3 | TableFields = (mappings as list ) as list => List.Transform(mappings, each _[mapped]), 4 | 5 | GeneralFields = { 6 | [api = "name", mapped = "name"], 7 | [api = "lastIpAddress", mapped = "computerDetails.ip_address"], 8 | [api = "lastReportedIp", mapped = "computerDetails.last_reported_ip"], 9 | [api = "jamfBinaryVersion", mapped = "computerDetails.jamf_version"], 10 | [api = "platform", mapped = "computerDetails.platform"], 11 | [api = "barcode1", mapped = "computerDetails.barcode_1"], 12 | [api = "barcode2", mapped = "computerDetails.barcode_2"], 13 | [api = "assetTag", mapped = "computerDetails.asset_tag"], 14 | [api = "remoteManagement", mapped = "remoteManagement"], 15 | [api = "mdmCapable", mapped = "mdmCapable"], 16 | [api = "reportDate", mapped = "computerDetails.report_date_utc"], 17 | [api = "lastContactTime", mapped = "computerDetails.last_contact_time_utc"], 18 | [api = "lastCloudBackupDate", mapped = "computerDetails.last_cloud_backup_date_utc"], 19 | [api = "lastEnrolledDate", mapped = "computerDetails.last_enrolled_date_utc"], 20 | [api = "initialEntryDate", mapped = "computerDetails.initial_entry_date_utc"], 21 | [api = "distributionPoint", mapped = "computerDetails.distribution_point"], 22 | [api = "site", mapped = "computerDetails.site"], 23 | [api = "itunesStoreAccountActive", mapped = "computerDetails.itunes_store_account_is_active"] 24 | }, 25 | 26 | RemoteManagementFields = { 27 | [api = "managed", mapped = "computerDetails.remote_management.managed"], 28 | [api = "managementUsername", mapped = "computerDetails.remote_management.management_username"] 29 | }, 30 | 31 | MdmCapableFields = { 32 | [api = "capable", mapped = "computerDetails.mdm_capable"], 33 | [api = "capableUsers", mapped = "computerDetails.mdm_capable_users.mdm_capable_user"] 34 | }, 35 | 36 | PurchasingFields = { 37 | [api = "purchased", mapped = "computerDetails.is_purchased"], 38 | [api = "leased", mapped = "computerDetails.is_leased"], 39 | [api = "poNumber", mapped = "computerDetails.po_number"], 40 | [api = "lifeExpectancy", mapped = "computerDetails.life_expectancy"], 41 | [api = "purchasePrice", mapped = "computerDetails.purchase_price"], 42 | [api = "purchasingAccount", mapped = "computerDetails.purchasing_account"], 43 | [api = "purchasingContact", mapped = "computerDetails.purchasing_contact"], 44 | [api = "appleCareId", mapped = "computerDetails.applecare_id"], 45 | [api = "vendor", mapped = "computerDetails.vendor"], 46 | [api = "leaseDate", mapped = "computerDetails.lease_expires_utc"], 47 | [api = "poDate", mapped = "computerDetails.po_date_utc"], 48 | [api = "warrantyDate", mapped = "computerDetails.warranty_expires_utc"] 49 | }, 50 | 51 | UserAndLocationFields = { 52 | [api = "username", mapped = "computerDetails.username"], 53 | [api = "realname", mapped = "computerDetails.realname"], 54 | [api = "email", mapped = "computerDetails.email_address"], 55 | [api = "position", mapped = "computerDetails.position"], 56 | [api = "phone", mapped = "computerDetails.phone"], 57 | [api = "departmentId", mapped = "computerDetails.department"], 58 | [api = "buildingId", mapped = "computerDetails.building"], 59 | [api = "room", mapped = "computerDetails.room"] 60 | }, 61 | 62 | HardwareFields = { 63 | [api = "make", mapped = "computerDetails.make"], 64 | [api = "model", mapped = "computerDetails.model"], 65 | [api = "modelIdentifier", mapped = "computerDetails.model_identifier"], 66 | [api = "serialNumber", mapped = "computerDetails.serial_number"], 67 | [api = "processorSpeedMhz", mapped = "computerDetails.processor_speed_mhz"], 68 | [api = "processorCount", mapped = "computerDetails.number_processors"], 69 | [api = "coreCount", mapped = "computerDetails.number_cores"], 70 | [api = "processorType", mapped = "computerDetails.processor_type"], 71 | [api = "processorArchitecture", mapped = "computerDetails.processor_architecture"], 72 | [api = "busSpeedMhz", mapped = "computerDetails.bus_speed_mhz"], 73 | [api = "cacheSizeKilobytes", mapped = "computerDetails.cache_size_kb"], 74 | [api = "macAddress", mapped = "computerDetails.mac_address"], 75 | [api = "altMacAddress", mapped = "computerDetails.alt_mac_address"], 76 | [api = "totalRamMegabytes", mapped = "computerDetails.total_ram_mb"], 77 | [api = "openRamSlots", mapped = "computerDetails.available_ram_slots"], 78 | [api = "batteryCapacityPercent", mapped = "computerDetails.battery_capacity"], 79 | [api = "smcVersion", mapped = "computerDetails.smc_version"], 80 | [api = "nicSpeed", mapped = "computerDetails.nic_speed"], 81 | [api = "opticalDrive", mapped = "computerDetails.optical_drive"], 82 | [api = "bootRom", mapped = "computerDetails.boot_rom"], 83 | [api = "bleCapable", mapped = "computerDetails.ble_capable"] 84 | }, 85 | 86 | SecurityFields = { 87 | [api = "sipStatus", mapped = "computerDetails.sip_status"], 88 | [api = "gatekeeperStatus", mapped = "computerDetails.gatekeeper_status"], 89 | [api = "xprotectVersion", mapped = "computerDetails.xprotect_version"] 90 | }, 91 | 92 | OperatingSystemFields = { 93 | [api = "name", mapped = "computerDetails.os_name"], 94 | [api = "version", mapped = "computerDetails.os_version"], 95 | [api = "build", mapped = "computerDetails.os_build"], 96 | [api = "activeDirectoryStatus", mapped = "computerDetails.active_directory_status"] 97 | }, 98 | 99 | FieldsToDuplicate = { 100 | [original = "computerDetails.realname", duplicate = "computerDetails.real_name"], 101 | [original = "computerDetails.phone", duplicate = "computerDetails.phone_number"], 102 | [original = "computerDetails.warranty_expires_utc", duplicate = "computerDetails.warranty_expires"], 103 | [original = "computerDetails.lease_expires_utc", duplicate = "computerDetails.lease_expires"], 104 | [original = "computerDetails.po_date_utc", duplicate = "computerDetails.po_date"], 105 | [original = "computerDetails.report_date_utc", duplicate = "computerDetails.report_date"], 106 | [original = "computerDetails.last_contact_time_utc", duplicate = "computerDetails.last_contact_time"], 107 | [original = "computerDetails.cache_size_kb", duplicate = "computerDetails.cache_size"], 108 | [original = "computerDetails.total_ram_mb", duplicate = "computerDetails.total_ram"], 109 | [original = "computerDetails.processor_speed_mhz", duplicate = "computerDetails.processor_speed"], 110 | [original = "computerDetails.bus_speed_mhz", duplicate = "computerDetails.bus_speed"], 111 | [original = "computerDetails.initial_entry_date_utc", duplicate = "computerDetails.initial_entry_date"] 112 | }, 113 | 114 | General = [api = ApiFields(GeneralFields), mapped = TableFields(GeneralFields)], 115 | RemoteManagement = [api = ApiFields(RemoteManagementFields), mapped = TableFields(RemoteManagementFields)], 116 | MdmCapable = [api = ApiFields(MdmCapableFields), mapped = TableFields(MdmCapableFields)], 117 | Purchasing = [api = ApiFields(PurchasingFields), mapped = TableFields(PurchasingFields)], 118 | UserAndLocation = [api = ApiFields(UserAndLocationFields), mapped = TableFields(UserAndLocationFields)], 119 | Hardware = [api = ApiFields(HardwareFields), mapped = TableFields(HardwareFields)], 120 | Security = [api = ApiFields(SecurityFields), mapped = TableFields(SecurityFields)], 121 | OperatingSystem = [api = ApiFields(OperatingSystemFields), mapped = TableFields(OperatingSystemFields)], 122 | 123 | /** 124 | * These date/time fields are both mapped directly and converted to an `_epoch` column 125 | * format for compatibility with the legacy model. See the `copyUtcColumnsToEpoch` 126 | * function for detail. 127 | */ 128 | UtcColumnNames = { 129 | "computerDetails.report_date_utc", 130 | "computerDetails.last_contact_time_utc", 131 | "computerDetails.last_cloud_backup_date_utc", 132 | "computerDetails.last_enrolled_date_utc", 133 | "computerDetails.initial_entry_date_utc", 134 | "computerDetails.lease_expires_utc", 135 | "computerDetails.po_date_utc", 136 | "computerDetails.warranty_expires_utc" 137 | }, 138 | 139 | /** 140 | * Fields that did not have a clear analog to the Jamf Pro API data model. To ensure 141 | * backward compatibility with existing datasets and visualizations, these fields are 142 | * added with `null` values for all rows. See the `addUnmappedColumns` function for 143 | * detail. 144 | */ 145 | UnmappedFields = { 146 | "computerDetails.country_name", 147 | "computerDetails.remote_management.management_password_sha256", 148 | "computerDetails.management_status", 149 | "computerDetails.sus", 150 | "computerDetails.netboot_server", 151 | "computerDetails.os_applecare_id", 152 | "computerDetails.os_maintenance_expires", 153 | "computerDetails.attachments", 154 | "computerDetails.master_password_set", 155 | "computerDetails.service_pack", 156 | "computerDetails.institutional_recovery_key", 157 | "computerDetails.disk_encryption_configuration", 158 | "computerDetails.filevault2_users", 159 | "computerDetails.mapped_printers" 160 | } 161 | ] -------------------------------------------------------------------------------- /JamfPro/JamfPro.pq: -------------------------------------------------------------------------------- 1 | [Version = "2.0.0"] 2 | section JamfPro; 3 | 4 | // Number of computer or mobile device records to request per page. 5 | PAGE_SIZE = 100; 6 | 7 | [DataSource.Kind = "JamfPro", Publish = "JamfPro.Publish"] 8 | // gets initial jamf URL from user; uses this to make requests 9 | shared JamfPro.Contents = Value.ReplaceType( 10 | JamfPro.JamfNavTable, 11 | type function ( 12 | jamfUrl as ( 13 | Uri.Type meta [ 14 | Documentation.Name = Extension.LoadString("FunctionParamJamfUrlName"), 15 | Documentation.FieldCaption = Extension.LoadString("FunctionParamJamfUrlFieldCaption"), 16 | Documentation.FieldDescription = Extension.LoadString("FunctionParamJamfUrlFieldDescription"), 17 | Documentation.FieldSampleValues = {"https://acme.jamfcloud.com"} 18 | ] 19 | ) 20 | ) as text meta [ 21 | Documentation.Name = Extension.LoadString("FunctionName") 22 | ] 23 | ); 24 | 25 | // sets initial navigation table that links to individual table queries 26 | shared JamfPro.JamfNavTable = (url as text) as table => 27 | let 28 | url = validateUrlScheme(url), 29 | computerPages = getTotalNumberOfPages(url, "v1/computers-inventory"), 30 | mobileDevicesPages = getTotalNumberOfPages(url, "v2/mobile-devices"), 31 | source = #table( 32 | {"Name", "Data", "ItemKind", "ItemName", "IsLeaf"}, 33 | { 34 | {"Computers", computers(url, computerPages), "Table", "Table", true}, 35 | {"Computer Device Groups", computerGroups(url), "Table", "Table", true}, 36 | {"Computers - Applications", computerApplications(url, computerPages), "Table", "Table", true}, 37 | {"Computers - Extension attributes", computerExtensionAttributes(url, computerPages), "Table", "Table", true}, 38 | {"Mobile Devices", mobileDevices(url, mobileDevicesPages), "Table", "Table", true}, 39 | {"Mobile - Applications", mobileDeviceApplications(url, mobileDevicesPages), "Table", "Table", true}, 40 | {"Mobile Device Groups", mobileDeviceGroups(url), "Table", "Table", true}, 41 | {"Mobile Devices - ExtensionAttributes", mobileDeviceExtensionAtttributes(url, mobileDevicesPages), "Table", "Table", true} 42 | } 43 | ), 44 | navTable = Table.ToNavigationTable(source, {"Name"}, "Name", "Data", "ItemKind", "ItemName", "IsLeaf") 45 | in 46 | navTable; 47 | 48 | validateUrlScheme = (url as text) as text => 49 | if (Uri.Parts(url)[Scheme] <> "https") then 50 | error "Url scheme must be HTTPS" 51 | else 52 | removeTrailingSlash(url); 53 | 54 | removeTrailingSlash = (url as text) as text => 55 | if Text.EndsWith(url, "/") then 56 | Text.RemoveRange(url, Text.Length(url) - 1, 1) 57 | else 58 | url; 59 | 60 | getTotalNumberOfPages = (url as text, relativePath as text) as number => 61 | let 62 | totalCount = UAPIResource( 63 | url, "api/" & relativePath, [#"page-size" = Text.From(PAGE_SIZE)] 64 | )[totalCount], 65 | pages = if totalCount = 0 then 0 else Number.RoundUp(totalCount / PAGE_SIZE) 66 | in 67 | pages; 68 | 69 | getPageComputers = (url as text, sections as list, page as number) as list => 70 | getPage(url, sections, page, "api/v1/computers-inventory", "id"); 71 | 72 | getPageMobileDevices = (url as text, sections as list, page as number) as list => 73 | getPage(url, sections, page, "api/v2/mobile-devices/detail", "mobileDeviceId"); 74 | 75 | getPage = (url as text, sections as list, page as number, path as text, sortKey as text) as list => 76 | let 77 | result = UAPIResource( 78 | url, 79 | path, 80 | [ 81 | #"page-size" = Text.From(PAGE_SIZE), 82 | page = Text.From(page), 83 | section = sections, 84 | sort = sortKey 85 | ] 86 | )[results] 87 | in 88 | result; 89 | 90 | mobileDeviceGroups = (url as text) as table => 91 | let 92 | jsonDeviceGroups = JSSResource(url, "/mobiledevicegroups"), 93 | deviceGroups = Table.FromRecords(jsonDeviceGroups[mobile_device_groups]), 94 | selectedColumns = Table.SelectColumns(deviceGroups, {"id", "name", "is_smart"}), 95 | groupsPlusMembership = Table.AddColumn( 96 | selectedColumns, "temp_column", each getMobileDeviceMembership([id], url), type list 97 | ), 98 | tempTable = Table.ExpandListColumn(groupsPlusMembership, "temp_column"), 99 | mobileDeviceGroupExpanded = Table.ExpandRecordColumn( 100 | tempTable, 101 | "temp_column", 102 | {"id", "name", "mac_address", "udid", "wifi_mac_address", "serial_number"}, 103 | { 104 | "device_id", 105 | "device_name", 106 | "device_mac_address", 107 | "device_udid", 108 | "device_wifi_mac_address", 109 | "device_serial_number" 110 | } 111 | ) 112 | in 113 | mobileDeviceGroupExpanded; 114 | 115 | computerGroups = (url as text) as table => 116 | let 117 | jsonComputerGroups = JSSResource(url, "/computergroups"), 118 | computerGroups = Table.FromRecords(jsonComputerGroups[computer_groups]), 119 | selectedColumns = Table.SelectColumns(computerGroups, {"id", "name", "is_smart"}), 120 | computerGroupsModified = Table.AddColumn( 121 | selectedColumns, "temp_column", each getComputerGroupMembershipIds([id], url), type list 122 | ), 123 | tempTable = Table.ExpandListColumn(computerGroupsModified, "temp_column"), 124 | computerGroupsModifiedExpanded = Table.ExpandRecordColumn( 125 | tempTable, "temp_column", {"id", "name"}, {"computer_id", "computer_name"} 126 | ) 127 | in 128 | computerGroupsModifiedExpanded; 129 | 130 | getComputerGroupMembershipIds = (id as number, baseUrl as text) as list => 131 | let 132 | jsonComputerGroupMembers = JSSResource(baseUrl, "/computergroups/id/" & Number.ToText(id)), 133 | computerGroupMembers = try jsonComputerGroupMembers[computer_group][computers] as list otherwise {} 134 | in 135 | computerGroupMembers; 136 | 137 | getMobileDeviceMembership = (id as number, baseUrl as text) as list => 138 | let 139 | jsonMobileDeviceGroupMembers = JSSResource(baseUrl, "/mobiledevicegroups/id/" & Number.ToText(id)), 140 | mobileDeviceGroupMembers = 141 | try jsonMobileDeviceGroupMembers[mobile_device_group][mobile_devices] as list otherwise {} 142 | in 143 | mobileDeviceGroupMembers; 144 | 145 | // this function acts as our API call for queries 146 | UAPIResource = ( 147 | baseUrl as text, 148 | relativepath as text, 149 | optional query as record, 150 | optional token as text, 151 | optional attempts as number 152 | ) => 153 | let 154 | number_of_attempts = if attempts = null then 1 else (attempts + 1), 155 | response = 156 | if token = null then 157 | Web.Contents( 158 | baseUrl, 159 | [ 160 | Headers = [ 161 | #"Accept" = "application/json", 162 | #"Authorization" = TokenAuthorizationHeader(baseUrl) 163 | ], 164 | RelativePath = relativepath, 165 | ManualStatusHandling = {429, 401}, 166 | IsRetry = attempts <> null, 167 | Query = query 168 | ] 169 | ) 170 | else 171 | Web.Contents( 172 | baseUrl, 173 | [ 174 | Headers = [ 175 | #"Accept" = "application/json", 176 | #"Authorization" = token 177 | ], 178 | RelativePath = relativepath, 179 | Timeout = Duration.FromText("01:00:00.0"), 180 | ManualStatusHandling = {429, 401}, 181 | IsRetry = attempts <> null, 182 | Query = query 183 | ] 184 | ), 185 | responseMetadata = Value.Metadata(response), 186 | responseCode = responseMetadata[Response.Status], 187 | responseHeaders = responseMetadata[Headers], 188 | json = 189 | if responseCode <> 200 and number_of_attempts < 3 then 190 | Function.InvokeAfter( 191 | () => 192 | @UAPIResource( 193 | baseUrl, 194 | relativepath, 195 | query, 196 | TokenAuthorizationHeader(baseUrl, true), 197 | number_of_attempts 198 | ), 199 | #duration(0, 0, 0, 3) 200 | ) 201 | else 202 | Json.Document(response) 203 | in 204 | json; 205 | 206 | // this function is the API call for classic Jamf Pro API data 207 | JSSResource = ( 208 | baseUrl as text, relativepath as text, optional token as text, optional attempts as number 209 | ) => 210 | let 211 | numberOfAttempts = if attempts = null then 1 else (attempts + 1), 212 | response = 213 | if token = null then 214 | Web.Contents( 215 | baseUrl & "/JSSResource", 216 | [ 217 | Headers = [ 218 | #"Accept" = "application/json", 219 | #"Authorization" = TokenAuthorizationHeader(baseUrl) 220 | ], 221 | RelativePath = relativepath 222 | ] 223 | ) 224 | else 225 | Web.Contents( 226 | baseUrl, 227 | [ 228 | Headers = [ 229 | #"Accept" = "application/json", 230 | #"Authorization" = token 231 | ], 232 | RelativePath = relativepath, 233 | Timeout = Duration.FromText("01:00:00.0"), 234 | ManualStatusHandling = {429, 401}, 235 | IsRetry = attempts <> null 236 | ] 237 | ), 238 | responseMetadata = Value.Metadata(response), 239 | responseCode = responseMetadata[Response.Status], 240 | responseHeaders = responseMetadata[Headers], 241 | json = 242 | if responseCode <> 200 and numberOfAttempts < 3 then 243 | Function.InvokeAfter( 244 | () => 245 | @JSSResource( 246 | baseUrl, relativepath, TokenAuthorizationHeader(baseUrl, true), 247 | numberOfAttempts 248 | ), 249 | #duration(0, 0, 0, 3) 250 | ) 251 | else 252 | Json.Document(response) 253 | in 254 | json; 255 | 256 | // gets token for auth header in API call 257 | TokenAuthorizationHeader = (baseUrl as text, optional isRetry as logical) => 258 | let 259 | credentials = Extension.CurrentCredential(), 260 | token = 261 | if credentials[AuthenticationKind] = "Key" then 262 | let 263 | clientIdAndSecret = Text.Split(credentials[Key], ":"), 264 | accessToken = Json.Document( 265 | Web.Contents( 266 | baseUrl, 267 | [ 268 | Headers = [#"Content-Type" = "application/x-www-form-urlencoded"], 269 | RelativePath = "/api/v1/oauth/token", 270 | Content = Text.ToBinary( 271 | "client_id=" 272 | & clientIdAndSecret{0} 273 | & "&client_secret=" 274 | & clientIdAndSecret{1} 275 | & "&grant_type=client_credentials" 276 | ), 277 | IsRetry = if isRetry <> null then isRetry else false 278 | ] 279 | ) 280 | )[access_token] 281 | in 282 | accessToken 283 | else 284 | Json.Document( 285 | Web.Contents( 286 | baseUrl, 287 | [ 288 | Headers = [], 289 | RelativePath = "/api/v1/auth/token", 290 | Content = Text.ToBinary(""), 291 | IsRetry = if isRetry <> null then isRetry else false 292 | ] 293 | ) 294 | )[token], 295 | 296 | token_header = "Bearer " & token 297 | in 298 | token_header; 299 | 300 | computerApplications = (url as text, totalPagesCount as number) as table => 301 | let 302 | fieldNamesFromApi = {"name", "path", "version"}, 303 | fieldNamesForTable = { 304 | "computerDetails.computerApplications.name", 305 | "computerDetails.computerApplications.path", 306 | "computerDetails.computerApplications.version" 307 | }, 308 | emptyTable = #table(List.Combine({{"id"}, fieldNamesForTable}), {}), 309 | result = 310 | if totalPagesCount = 0 then 311 | emptyTable 312 | else 313 | let 314 | pages = {0..totalPagesCount - 1}, 315 | listOfPages = List.Transform( 316 | pages, each getPageComputers(url, {"GENERAL", "APPLICATIONS"}, _) 317 | ), 318 | rowPerComputer = List.Combine(listOfPages), 319 | data = Table.FromRecords(rowPerComputer), 320 | selectedColumns = Table.SelectColumns(data, {"id", "general", "applications"}), 321 | expandGeneral = Table.ExpandRecordColumn(selectedColumns, "general", {"name"}), 322 | expandApplications = Table.ExpandListColumn(expandGeneral, "applications"), 323 | expandedApplicationsWithFieldNames = Table.ExpandRecordColumn( 324 | expandApplications, "applications", fieldNamesFromApi, fieldNamesForTable 325 | ) 326 | in 327 | expandedApplicationsWithFieldNames 328 | in 329 | result; 330 | 331 | // Computer lookup and mapping function. See notes on mobileDevices function for backward compatibility. 332 | computers = (url as text, totalPagesCount as number) as table => 333 | let 334 | tableFieldsTopLevel = {"id", "computerDetails.udid"}, 335 | emptyTable = #table( 336 | List.Combine( 337 | { 338 | tableFieldsTopLevel, 339 | ComputerMappings[General][mapped] 340 | } 341 | ), 342 | {} 343 | ), 344 | result = 345 | if totalPagesCount = 0 then 346 | emptyTable 347 | else 348 | let 349 | pages = {0..totalPagesCount - 1}, 350 | listOfPages = List.Transform( 351 | pages, 352 | each 353 | getPageComputers( 354 | url, {"GENERAL", "DISK_ENCRYPTION", "PURCHASING", "USER_AND_LOCATION", "HARDWARE", "SECURITY", "OPERATING_SYSTEM"}, _ 355 | ) 356 | ), 357 | rowPerDevice = List.Combine(listOfPages), 358 | selectedColumns = Table.FromRecords( 359 | rowPerDevice, 360 | {"id", "udid", "general", "purchasing", "userAndLocation", "hardware", "security", "operatingSystem"}, 361 | MissingField.UseNull 362 | ), 363 | renameUdid = Table.RenameColumns(selectedColumns, {{"udid", "computerDetails.udid"}}), 364 | expandGeneral = Table.ExpandRecordColumn(renameUdid, "general", ComputerMappings[General][api], ComputerMappings[General][mapped]), 365 | expandRemoteManagement = Table.ExpandRecordColumn(expandGeneral, "remoteManagement", ComputerMappings[RemoteManagement][api], ComputerMappings[RemoteManagement][mapped]), 366 | expandMdmCapable = Table.ExpandRecordColumn(expandRemoteManagement, "mdmCapable", ComputerMappings[MdmCapable][api], ComputerMappings[MdmCapable][mapped]), 367 | expandPurchasing = Table.ExpandRecordColumn(expandMdmCapable, "purchasing", ComputerMappings[Purchasing][api], ComputerMappings[Purchasing][mapped]), 368 | expandUserAndLocation = Table.ExpandRecordColumn(expandPurchasing, "userAndLocation", ComputerMappings[UserAndLocation][api], ComputerMappings[UserAndLocation][mapped]), 369 | expandHardware = Table.ExpandRecordColumn(expandUserAndLocation, "hardware", ComputerMappings[Hardware][api], ComputerMappings[Hardware][mapped]), 370 | expandSecurity = Table.ExpandRecordColumn(expandHardware, "security", ComputerMappings[Security][api], ComputerMappings[Security][mapped]), 371 | expandOperatingSystem = Table.ExpandRecordColumn(expandSecurity, "operatingSystem", ComputerMappings[OperatingSystem][api], ComputerMappings[OperatingSystem][mapped]), 372 | 373 | // add any unmapped columns to the final table for backward compatibility 374 | withUnmappedColumns = addUnmappedColumns(expandOperatingSystem, ComputerMappings[UnmappedFields]), 375 | withEpochColumns = copyUtcColumnsToEpoch(withUnmappedColumns, ComputerMappings[UtcColumnNames]), 376 | withDuplicateColumns = duplicateColumns(withEpochColumns, ComputerMappings[FieldsToDuplicate]) 377 | in 378 | withDuplicateColumns 379 | in 380 | result; 381 | 382 | /** 383 | * Mobile device lookup and mapping function. 384 | * 385 | * Notes about backward compatibility with the legacy 1.0.0-beta version of the connector: 386 | * - "api" fields are those returned by the Jamf Pro API and "mapped" are the fields that will 387 | * be displayed in the Power BI table. The remapping was necessary to limit the number of 388 | * changes to the legacy data model, in an effort to preserve existing data sets and 389 | * visualizations where possible. See MobileDeviceFieldMappings.pqm for the list of mappings. 390 | */ 391 | mobileDevices = (url as text, totalPagesCount as number) as table => 392 | let 393 | apiFieldsTopLevel = {"mobileDeviceId", "deviceType"}, 394 | tableFieldsTopLevel = {"id", "mobileDeviceDetails.os_type"}, 395 | 396 | emptyTable = #table( 397 | List.Combine( 398 | { 399 | tableFieldsTopLevel, 400 | MobileDeviceMappings[General][mapped], 401 | MobileDeviceMappings[Hardware][mapped], 402 | MobileDeviceMappings[UserAndLocation][mapped], 403 | MobileDeviceMappings[Security][mapped], 404 | MobileDeviceMappings[LostModeLocation][mapped], 405 | MobileDeviceMappings[Purchasing][mapped], 406 | MobileDeviceMappings[Network][mapped] 407 | } 408 | ), 409 | {} 410 | ), 411 | result = 412 | if totalPagesCount = 0 then 413 | emptyTable 414 | else 415 | let 416 | pages = {0..totalPagesCount - 1}, 417 | listOfPages = List.Transform( 418 | pages, 419 | each 420 | getPageMobileDevices( 421 | url, {"GENERAL", "HARDWARE", "USER_AND_LOCATION", "SECURITY", "NETWORK", "PURCHASING"}, _ 422 | ) 423 | ), 424 | rowPerDevice = List.Combine(listOfPages), 425 | selectedColumns = Table.FromRecords( 426 | rowPerDevice, 427 | {"mobileDeviceId", "deviceType", "general", "hardware", "userAndLocation", "security", "purchasing", "network"}, 428 | MissingField.UseNull 429 | ), 430 | renameTopLevelFields = Table.RenameColumns(selectedColumns, List.Zip({apiFieldsTopLevel, tableFieldsTopLevel})), 431 | expandGeneral = Table.ExpandRecordColumn(renameTopLevelFields, "general", MobileDeviceMappings[General][api], MobileDeviceMappings[General][mapped]), 432 | expandHardware = Table.ExpandRecordColumn(expandGeneral, "hardware", MobileDeviceMappings[Hardware][api], MobileDeviceMappings[Hardware][mapped]), 433 | expandUserAndLocation = Table.ExpandRecordColumn(expandHardware, "userAndLocation", MobileDeviceMappings[UserAndLocation][api], MobileDeviceMappings[UserAndLocation][mapped]), 434 | expandSecurity = Table.ExpandRecordColumn(expandUserAndLocation, "security", MobileDeviceMappings[Security][api], MobileDeviceMappings[Security][mapped]), 435 | expandLostModeLocation = Table.ExpandRecordColumn(expandSecurity, "lostModeLocation", MobileDeviceMappings[LostModeLocation][api], MobileDeviceMappings[LostModeLocation][mapped]), 436 | expandPurchasing = Table.ExpandRecordColumn(expandLostModeLocation, "purchasing", MobileDeviceMappings[Purchasing][api], MobileDeviceMappings[Purchasing][mapped]), 437 | expandNetwork = Table.ExpandRecordColumn(expandPurchasing, "network", MobileDeviceMappings[Network][api], MobileDeviceMappings[Network][mapped]) 438 | in 439 | expandNetwork, 440 | 441 | withUnmappedColumns = addUnmappedColumns(result, MobileDeviceMappings[UnmappedFields]), 442 | withEpochColumns = copyUtcColumnsToEpoch(withUnmappedColumns, MobileDeviceMappings[UtcColumnNames]), 443 | withDuplicateColumns = duplicateColumns(withEpochColumns, MobileDeviceMappings[FieldsToDuplicate]) 444 | in 445 | withDuplicateColumns; 446 | 447 | mobileDeviceApplications = (url as text, totalPagesCount as number) as table => 448 | let 449 | apiFieldsGeneral = {"displayName", "udid", "managed", "supervised"}, 450 | tableFieldsGeneral = {"name", "udid", "managed", "supervised"}, 451 | apiFieldsHardware = {"serialNumber", "wifiMacAddress", "model", "modelIdentifier"}, 452 | tableFieldsHardware = {"serial_number", "wifi_mac_address", "model", "model_identifier"}, 453 | apiFieldsApps = {"name", "version", "identifier"}, 454 | tableFieldsApps = { 455 | "mobileDeviceDetails.mdApps.application_name", 456 | "mobileDeviceDetails.mdApps.application_version", 457 | "mobileDeviceDetails.mdApps.identifier" 458 | }, 459 | apiFieldsUserAndLocation = {"phoneNumber", "username"}, 460 | tableFieldsUserAndLocation = {"phone_number", "username"}, 461 | emptyTable = #table( 462 | List.Combine( 463 | {{"id"}, tableFieldsGeneral, tableFieldsHardware, tableFieldsUserAndLocation, tableFieldsApps} 464 | ), 465 | {} 466 | ), 467 | result = 468 | if totalPagesCount = 0 then 469 | emptyTable 470 | else 471 | let 472 | pages = {0..totalPagesCount - 1}, 473 | listOfPages = List.Transform( 474 | pages, 475 | each 476 | getPageMobileDevices(url, {"GENERAL", "APPLICATIONS", "HARDWARE", "USER_AND_LOCATION"}, _) 477 | ), 478 | rowPerDevice = List.Combine(listOfPages), 479 | selectedColumns = Table.FromRecords( 480 | rowPerDevice, 481 | {"mobileDeviceId", "general", "hardware", "userAndLocation", "applications"}, 482 | MissingField.UseNull 483 | ), 484 | renameIdField = Table.RenameColumns(selectedColumns, {{"mobileDeviceId", "id"}}), 485 | expandGeneral = Table.ExpandRecordColumn(renameIdField, "general", apiFieldsGeneral, tableFieldsGeneral), 486 | expandHardware = Table.ExpandRecordColumn(expandGeneral, "hardware", apiFieldsHardware, tableFieldsHardware), 487 | expandUserAndLocation = Table.ExpandRecordColumn(expandHardware, "userAndLocation", apiFieldsUserAndLocation, tableFieldsUserAndLocation), 488 | normalizeApps = Table.ExpandListColumn(expandUserAndLocation, "applications"), 489 | expandApps = Table.ExpandRecordColumn(normalizeApps, "applications", apiFieldsApps, tableFieldsApps) 490 | in 491 | expandApps, 492 | 493 | withDuplicateColumns = duplicateColumns(result, MobileDeviceMappings[AppsAndEAsFieldsToDuplicate]) 494 | in 495 | withDuplicateColumns; 496 | 497 | computerExtensionAttributes = (url as text, totalPagesCount as number) as table => 498 | let 499 | fieldNamesForTable = {"computerDetails.extAttrs.id", "computerDetails.extAttrs.name", "computerDetails.extAttrs.type", "computerDetails.extAttrs.value"} , 500 | emptyTable = #table(List.Combine({{"id", "name"}, fieldNamesForTable}), {}), 501 | result = 502 | if totalPagesCount = 0 then 503 | emptyTable 504 | else 505 | let 506 | pages = {0..totalPagesCount - 1}, 507 | listOfPages = List.Transform( 508 | pages, each getPageComputers( 509 | url, {"GENERAL", "HARDWARE", "USER_AND_LOCATION", "PURCHASING", "OPERATING_SYSTEM", "EXTENSION_ATTRIBUTES"}, _ 510 | ) 511 | ), 512 | rowPerComputer = List.Combine(listOfPages), 513 | data = Table.FromRecords(rowPerComputer), 514 | selectedColumns = Table.SelectColumns(data, {"id", "general", "hardware", "userAndLocation", "purchasing", "operatingSystem", "extensionAttributes"}), 515 | 516 | expandGeneral = Table.ExpandRecordColumn(selectedColumns, "general", {"name", "extensionAttributes"}, {"name", "general.extensionAttributes"}), 517 | expandHardware = Table.ExpandRecordColumn(expandGeneral, "hardware", {"extensionAttributes"}, {"hardware.extensionAttributes"}), 518 | expandUserAndLocation = Table.ExpandRecordColumn(expandHardware, "userAndLocation", {"extensionAttributes"}, {"userAndLocation.extensionAttributes"}), 519 | expandPurchasing = Table.ExpandRecordColumn(expandUserAndLocation, "purchasing", {"extensionAttributes"}, {"purchasing.extensionAttributes"}), 520 | expandOperatingSystem = Table.ExpandRecordColumn(expandPurchasing, "operatingSystem", {"extensionAttributes"}, {"operatingSystem.extensionAttributes"}), 521 | 522 | eaApiFields = [id = "definitionId", name = "name", dataType = "dataType", value = "values"], 523 | eaTableFields = [id = "computerDetails.extAttrs.id", name = "computerDetails.extAttrs.name", dataType = "computerDetails.extAttrs.type", value = "computerDetails.extAttrs.value"], 524 | generalNormalized = normalizeEAs(expandOperatingSystem, "general.extensionAttributes", eaApiFields, eaTableFields), 525 | hardwareNormalized = normalizeEAs(expandOperatingSystem, "hardware.extensionAttributes", eaApiFields, eaTableFields), 526 | userAndLocationNormalized = normalizeEAs(expandOperatingSystem, "userAndLocation.extensionAttributes", eaApiFields, eaTableFields), 527 | purchasingNormalized = normalizeEAs(expandOperatingSystem, "purchasing.extensionAttributes", eaApiFields, eaTableFields), 528 | operatingSystemNormalized = normalizeEAs(expandOperatingSystem, "operatingSystem.extensionAttributes", eaApiFields, eaTableFields), 529 | extensionAttributesNormalized = normalizeEAs(expandOperatingSystem, "extensionAttributes", eaApiFields, eaTableFields), 530 | combinedEAs = Table.Combine({generalNormalized, hardwareNormalized, userAndLocationNormalized, purchasingNormalized, operatingSystemNormalized, extensionAttributesNormalized}), 531 | 532 | finalColumns = Table.SelectColumns(combinedEAs, List.Combine({{"id", "name"}, fieldNamesForTable})) 533 | in 534 | finalColumns 535 | in 536 | result; 537 | 538 | mobileDeviceExtensionAtttributes = (url as text, totalPagesCount as number) as table => 539 | let 540 | apiFieldsGeneral = {"displayName", "udid", "managed", "supervised", "extensionAttributes"}, 541 | tableFieldsGeneral = {"name", "udid", "managed", "supervised", "general.extensionAttributes"}, 542 | apiFieldsHardware = {"serialNumber", "wifiMacAddress", "model", "modelIdentifier", "extensionAttributes"}, 543 | tableFieldsHardware = {"serial_number", "wifi_mac_address", "model", "model_identifier", "hardware.extensionAttributes"}, 544 | apiFieldsUserAndLocation = {"phoneNumber", "username", "extensionAttributes"}, 545 | tableFieldsUserAndLocation = {"phone_number", "username", "userAndLocation.extensionAttributes"}, 546 | 547 | // final column names, including tableFieldsGeneral, tableFieldsHardware, and tableFieldsUserAndLocation, minus anything with `extensionAttributes` in it 548 | eaFieldNames = {"ExtAttrId", "ExtAttrName", "ExtAttrType", "ExtAttrVal"}, 549 | finalColumnNames = List.Combine( 550 | { 551 | {"id"}, 552 | List.RemoveItems(tableFieldsGeneral, {"general.extensionAttributes"}), 553 | List.RemoveItems(tableFieldsHardware, {"hardware.extensionAttributes"}), 554 | List.RemoveItems(tableFieldsUserAndLocation, {"userAndLocation.extensionAttributes"}), 555 | eaFieldNames 556 | } 557 | ), 558 | 559 | emptyTable = #table(finalColumnNames, {}), 560 | result = 561 | if totalPagesCount = 0 then 562 | emptyTable 563 | else 564 | let 565 | pages = {0..totalPagesCount - 1}, 566 | listOfPages = List.Transform( 567 | pages, 568 | each 569 | getPageMobileDevices(url, {"GENERAL", "HARDWARE", "USER_AND_LOCATION", "PURCHASING", "EXTENSION_ATTRIBUTES"}, _) 570 | ), 571 | rowPerDevice = List.Combine(listOfPages), 572 | selectedColumns = Table.FromRecords( 573 | rowPerDevice, 574 | {"mobileDeviceId", "general", "hardware", "userAndLocation", "purchasing", "extensionAttributes"}, 575 | MissingField.UseNull 576 | ), 577 | renameIdField = Table.RenameColumns(selectedColumns, {{"mobileDeviceId", "id"}}), 578 | expandGeneral = Table.ExpandRecordColumn(renameIdField, "general", apiFieldsGeneral, tableFieldsGeneral), 579 | expandHardware = Table.ExpandRecordColumn(expandGeneral, "hardware", apiFieldsHardware, tableFieldsHardware), 580 | expandUserAndLocation = Table.ExpandRecordColumn(expandHardware, "userAndLocation", apiFieldsUserAndLocation, tableFieldsUserAndLocation), 581 | expandPurchasing = Table.ExpandRecordColumn(expandUserAndLocation, "purchasing", {"extensionAttributes"}, {"purchasing.extensionAttributes"}), 582 | 583 | eaApiFields = [id = "id", name = "name", dataType = "type", value = "value"], 584 | eaTableFields = [id = "ExtAttrId", name = "ExtAttrName", dataType = "ExtAttrType", value = "ExtAttrVal"], 585 | generalNormalized = normalizeEAs(expandPurchasing, "general.extensionAttributes", eaApiFields, eaTableFields), 586 | userAndLocationNormalized = normalizeEAs(expandPurchasing, "userAndLocation.extensionAttributes", eaApiFields, eaTableFields), 587 | hardwareNormalized = normalizeEAs(expandPurchasing, "hardware.extensionAttributes", eaApiFields, eaTableFields), 588 | purchasingNormalized = normalizeEAs(expandPurchasing, "purchasing.extensionAttributes", eaApiFields, eaTableFields), 589 | extensionAttributesNormalized = normalizeEAs(expandPurchasing, "extensionAttributes", eaApiFields, eaTableFields), 590 | combinedEAs = Table.Combine({generalNormalized, userAndLocationNormalized, hardwareNormalized, purchasingNormalized, extensionAttributesNormalized}) 591 | in 592 | combinedEAs, 593 | 594 | finalUniqueColumns = Table.SelectColumns(result, finalColumnNames), 595 | withDuplicateColumns = duplicateColumns(finalUniqueColumns, MobileDeviceMappings[AppsAndEAsFieldsToDuplicate]) 596 | in 597 | withDuplicateColumns; 598 | 599 | normalizeEAs = (t as table, columnName as text, apiFields as record, tableFields as record) as table => 600 | let 601 | normalizeEAs = Table.ExpandListColumn(t, columnName), 602 | expandEAs = Table.ExpandRecordColumn( 603 | normalizeEAs, 604 | columnName, 605 | {apiFields[id], apiFields[name], apiFields[dataType], apiFields[value]}, 606 | {tableFields[id], tableFields[name], tableFields[dataType], tableFields[value]} 607 | ), 608 | expandValues = Table.ExpandListColumn(expandEAs, tableFields[value]), 609 | filteredEAs = Table.SelectRows(expandValues, each Record.Field(_, tableFields[id]) <> null), 610 | result = Table.RemoveColumns(filteredEAs, {columnName}, MissingField.Ignore) 611 | in 612 | result; 613 | 614 | dateStringIso8601ToEpochMillis = (dateString as text) as number => 615 | let 616 | dateObject = DateTime.FromText(dateString), 617 | epochMillis = Duration.TotalSeconds(Duration.From(dateObject - #datetime(1970, 1, 1, 0, 0, 0))) * 1000 618 | in 619 | epochMillis; 620 | 621 | copyUtcColumnsToEpoch = (t as table, utcColumnNames as list) as table => 622 | let 623 | // Generate new column names by replacing "_utc" with "_epoch" 624 | epochColumnNames = List.Transform(utcColumnNames, each Text.Replace(_, "_utc", "_epoch")), 625 | 626 | // Convert each UTC column to epoch milliseconds and add as a new column 627 | addEpochColumns = List.Accumulate( 628 | List.Zip({utcColumnNames, epochColumnNames}), 629 | t, 630 | (state, current) => 631 | let 632 | utcColumnName = current{0}, 633 | epochColumnName = current{1}, 634 | addColumn = Table.AddColumn(state, epochColumnName, each try dateStringIso8601ToEpochMillis(Record.Field(_, utcColumnName)) otherwise 0, type number) 635 | in 636 | addColumn 637 | ) 638 | in 639 | addEpochColumns; 640 | 641 | addUnmappedColumns = (t as table, unmappedFields as list) as table => 642 | let 643 | result = List.Accumulate( 644 | unmappedFields, 645 | t, 646 | (state, current) => 647 | try 648 | Table.AddColumn(state, current, each null, type any) 649 | otherwise 650 | state 651 | ) 652 | in 653 | result; 654 | 655 | duplicateColumns = (t as table, fieldsToDuplicate as list) as table => 656 | let 657 | result = List.Accumulate( 658 | fieldsToDuplicate, 659 | t, 660 | (state, current) => 661 | let 662 | addColumn = Table.AddColumn(state, current[duplicate], each Record.Field(_, current[original]), type any) 663 | in 664 | addColumn 665 | ) 666 | in 667 | result; 668 | 669 | // Data Source definition 670 | JamfPro = [ 671 | TestConnection = (dataSourcePath) => {"JamfPro.JamfNavTable", dataSourcePath}, 672 | Authentication = [ 673 | UsernamePassword = [ 674 | Label = Extension.LoadString("AuthLabelUsernamePassword") 675 | ], 676 | Key = [ 677 | Label = Extension.LoadString("AuthLabelApiClientKey"), 678 | KeyLabel = Extension.LoadString("AuthLabelApiClientKeyLabel") 679 | ] 680 | ], 681 | Label = Extension.LoadString("DataSourceLabel") 682 | ]; 683 | 684 | // Data Source UI publishing description 685 | JamfPro.Publish = [ 686 | Beta = false, 687 | Category = "Other", 688 | ButtonText = {Extension.LoadString("ButtonTitle"), Extension.LoadString("ButtonHelp")}, 689 | LearnMoreUrl = "https://marketplace.jamf.com/details/power-bi", 690 | SourceImage = JamfPro__Get_Devices.Icons, 691 | SourceTypeImage = JamfPro__Get_Devices.Icons 692 | ]; 693 | 694 | JamfPro__Get_Devices.Icons = [ 695 | Icon16 = { 696 | Extension.Contents("JamfPro16.png"), 697 | Extension.Contents("JamfPro20.png"), 698 | Extension.Contents("JamfPro24.png"), 699 | Extension.Contents("JamfPro32.png") 700 | }, 701 | Icon32 = { 702 | Extension.Contents("JamfPro32.png"), 703 | Extension.Contents("JamfPro40.png"), 704 | Extension.Contents("JamfPro48.png"), 705 | Extension.Contents("JamfPro64.png") 706 | } 707 | ]; 708 | 709 | Table.ToNavigationTable = ( 710 | table as table, 711 | keyColumns as list, 712 | nameColumn as text, 713 | dataColumn as text, 714 | itemKindColumn as text, 715 | itemNameColumn as text, 716 | isLeafColumn as text 717 | ) as table => 718 | let 719 | tableType = Value.Type(table), 720 | newTableType = Type.AddTableKey(tableType, keyColumns, true) meta [ 721 | NavigationTable.NameColumn = nameColumn, 722 | NavigationTable.DataColumn = dataColumn, 723 | NavigationTable.ItemKindColumn = itemKindColumn, 724 | Preview.DelayColumn = itemNameColumn, 725 | NavigationTable.IsLeafColumn = isLeafColumn 726 | ], 727 | navigationTable = Value.ReplaceType(table, newTableType) 728 | in 729 | navigationTable; 730 | 731 | Extension.LoadFunction = (fileName as text) => 732 | let 733 | binary = Extension.Contents(fileName), 734 | asText = Text.FromBinary(binary) 735 | in 736 | try 737 | Expression.Evaluate(asText, #shared) 738 | catch (e) => 739 | error [ 740 | Reason = "Extension.LoadFunction Failure", 741 | Message.Format = "Loading '#{0}' failed - '#{1}': '#{2}'", 742 | Message.Parameters = {fileName, e[Reason], e[Message]}, 743 | Detail = [File = fileName, Error = e] 744 | ]; 745 | 746 | MobileDeviceMappings = Extension.LoadFunction("MobileDeviceMappings.pqm"); 747 | ComputerMappings = Extension.LoadFunction("ComputerMappings.pqm"); 748 | -------------------------------------------------------------------------------- /JamfPro/JamfPro.proj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $(MSBuildProjectDirectory)\bin\AnyCPU\Debug\ 5 | $(MSBuildProjectDirectory)\obj\ 6 | $(IntermediateOutputPath)MEZ\ 7 | $(OutputPath)$(MsBuildProjectName).mez 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /JamfPro/JamfPro.query.pq: -------------------------------------------------------------------------------- 1 | // Use this file to write queries to test your data connector 2 | section Testing; 3 | 4 | // swap out the URL in Contents() for your own Jamf Pro instance URL 5 | // then add credentials in using the power query SDK credential helper for the instance's admin Username and Password 6 | result = JamfPro.Contents("https://yourjamfdomain.com"); 7 | 8 | shared Testing.UnitTest = [ 9 | // Put any common variables here if you only want them to be evaluated once 10 | // Fact(, , ) 11 | // and can be a literal or let statement 12 | facts = { 13 | // this test checks that the global navigation table for the query returns the correct number of global tables / rows 14 | // swap out the URL in JamfNavTable() for your own Jamf Pro instance URL 15 | // then add credentials in using the power query SDK credential helper for the instance's admin Username and Password 16 | Fact("Nav table should return 6 rows", 6, Table.RowCount(result)) 17 | }, 18 | report = Facts.Summarize(facts) 19 | ][report]; 20 | 21 | /// COMMON UNIT TESTING CODE 22 | Fact = (_subject as text, _expected, _actual) as record => 23 | [ 24 | expected = try _expected, 25 | safeExpected = if expected[HasError] then "Expected : " & @ValueToText(expected[Error]) else expected[Value], 26 | actual = try _actual, 27 | safeActual = if actual[HasError] then "Actual : " & @ValueToText(actual[Error]) else actual[Value], 28 | attempt = try safeExpected = safeActual, 29 | result = if attempt[HasError] or not attempt[Value] then "Failure ⛔" else "Success ✓", 30 | resultOp = if result = "Success ✓" then " = " else " <> ", 31 | addendumEvalAttempt = if attempt[HasError] then @ValueToText(attempt[Error]) else "", 32 | addendumEvalExpected = try @ValueToText(safeExpected) otherwise "...", 33 | addendumEvalActual = try @ValueToText(safeActual) otherwise "...", 34 | fact = [ 35 | Result = result & " " & addendumEvalAttempt, 36 | Notes = _subject, 37 | Details = " (" & addendumEvalExpected & resultOp & addendumEvalActual & ")" 38 | ] 39 | ][fact]; 40 | 41 | Facts = (_subject as text, _predicates as list) => List.Transform(_predicates, each Fact(_subject, _{0}, _{1})); 42 | 43 | Facts.Summarize = (_facts as list) as table => 44 | [ 45 | Fact.CountSuccesses = (count, i) => 46 | [ 47 | result = try i[Result], 48 | sum = if result[HasError] or not Text.StartsWith(result[Value], "Success") then count else count + 1 49 | ][sum], 50 | passed = List.Accumulate(_facts, 0, Fact.CountSuccesses), 51 | total = List.Count(_facts), 52 | format = if passed = total then "All #{0} Passed !!! ✓" else "#{0} Passed ☺ #{1} Failed ☹", 53 | result = if passed = total then "Success" else "⛔", 54 | rate = Number.IntegerDivide(100 * passed, total), 55 | header = [ 56 | Result = result, 57 | Notes = Text.Format(format, {passed, total - passed}), 58 | Details = Text.Format("#{0}% success rate", {rate}) 59 | ], 60 | report = Table.FromRecords(List.Combine({{header}, _facts})) 61 | ][report]; 62 | 63 | ValueToText = (value, optional depth) => 64 | let 65 | List.TransformAndCombine = (list, transform, separator) => 66 | Text.Combine(List.Transform(list, transform), separator), 67 | Serialize.Binary = (x) => "#binary(" & Serialize(Binary.ToList(x)) & ") ", 68 | Serialize.Function = (x) => 69 | _serialize_function_param_type( 70 | Type.FunctionParameters(Value.Type(x)), Type.FunctionRequiredParameters(Value.Type(x)) 71 | ) 72 | & " as " 73 | & _serialize_function_return_type(Value.Type(x)) 74 | & " => (...) ", 75 | Serialize.List = (x) => "{" & List.TransformAndCombine(x, Serialize, ", ") & "} ", 76 | Serialize.Record = (x) => 77 | "[ " 78 | & List.TransformAndCombine( 79 | Record.FieldNames(x), 80 | (item) => Serialize.Identifier(item) & " = " & Serialize(Record.Field(x, item)), 81 | ", " 82 | ) 83 | & " ] ", 84 | Serialize.Table = (x) => 85 | "#table( type " & _serialize_table_type(Value.Type(x)) & ", " & Serialize(Table.ToRows(x)) & ") ", 86 | Serialize.Identifier = Expression.Identifier, 87 | Serialize.Type = (x) => "type " & _serialize_typename(x), 88 | _serialize_typename = (x, optional funtype as logical) => 89 | // Optional parameter: Is this being used as part of a function signature? 90 | let 91 | isFunctionType = (x as type) => 92 | try if Type.FunctionReturn(x) is type then true else false otherwise false, 93 | isTableType = (x as type) => try if Type.TableSchema(x) is table then true else false otherwise false, 94 | isRecordType = (x as type) => try if Type.ClosedRecord(x) is type then true else false 95 | otherwise 96 | false, 97 | isListType = (x as type) => try if Type.ListItem(x) is type then true else false otherwise false 98 | in 99 | if funtype = null and isTableType(x) then 100 | _serialize_table_type(x) 101 | else if funtype = null and isListType(x) then 102 | "{ " & @_serialize_typename(Type.ListItem(x)) & " }" 103 | else if funtype = null and isFunctionType(x) then 104 | "function " & _serialize_function_type(x) 105 | else if funtype = null and isRecordType(x) then 106 | _serialize_record_type(x) 107 | else if x = type any then 108 | "any" 109 | else 110 | let 111 | base = Type.NonNullable(x) 112 | in 113 | (if Type.IsNullable(x) then "nullable " else "") 114 | & ( 115 | if base = type anynonnull then 116 | "anynonnull" 117 | else if base = type binary then 118 | "binary" 119 | else if base = type date then 120 | "date" 121 | else if base = type datetime then 122 | "datetime" 123 | else if base = type datetimezone then 124 | "datetimezone" 125 | else if base = type duration then 126 | "duration" 127 | else if base = type logical then 128 | "logical" 129 | else if base = type none then 130 | "none" 131 | else if base = type null then 132 | "null" 133 | else if base = type number then 134 | "number" 135 | else if base = type text then 136 | "text" 137 | else if base = type time then 138 | "time" 139 | else if base = type type then 140 | "type" 141 | else 142 | // Abstract types 143 | if base = type function then 144 | "function" 145 | else if base = type table then 146 | "table" 147 | else if base = type record then 148 | "record" 149 | else if base = type list then 150 | "list" 151 | else 152 | "any /*Actually unknown type*/" 153 | ), 154 | _serialize_table_type = (x) => 155 | let 156 | schema = Type.TableSchema(x) 157 | in 158 | "table " 159 | & ( 160 | if Table.IsEmpty(schema) then 161 | "" 162 | else 163 | "[" 164 | & List.TransformAndCombine( 165 | Table.ToRecords(Table.Sort(schema, "Position")), 166 | each Serialize.Identifier(_[Name]) & " = " & _[Kind], 167 | ", " 168 | ) 169 | & "] " 170 | ), 171 | _serialize_record_type = (x) => 172 | let 173 | flds = Type.RecordFields(x) 174 | in 175 | if Record.FieldCount(flds) = 0 then 176 | "record" 177 | else 178 | "[" 179 | & List.TransformAndCombine( 180 | Record.FieldNames(flds), 181 | (item) => 182 | Serialize.Identifier(item) & "=" & _serialize_typename( 183 | Record.Field(flds, item)[Type] 184 | ), 185 | ", " 186 | ) 187 | & (if Type.IsOpenRecord(x) then ", ..." else "") 188 | & "]", 189 | _serialize_function_type = (x) => 190 | _serialize_function_param_type(Type.FunctionParameters(x), Type.FunctionRequiredParameters(x)) 191 | & " as " 192 | & _serialize_function_return_type(x), 193 | _serialize_function_param_type = (t, n) => 194 | let 195 | funsig = Table.ToRecords( 196 | Table.TransformColumns( 197 | Table.AddIndexColumn(Record.ToTable(t), "isOptional", 1), {"isOptional", (x) => x > n} 198 | ) 199 | ) 200 | in 201 | "(" 202 | & List.TransformAndCombine( 203 | funsig, 204 | (item) => 205 | (if item[isOptional] then "optional " else "") 206 | & Serialize.Identifier(item[Name]) 207 | & " as " 208 | & _serialize_typename(item[Value], true), 209 | ", " 210 | ) 211 | & ")", 212 | _serialize_function_return_type = (x) => _serialize_typename(Type.FunctionReturn(x), true), 213 | Serialize = (x) as text => 214 | if x is binary then 215 | try Serialize.Binary(x) otherwise "null /*serialize failed*/" 216 | else if x is date then 217 | try Expression.Constant(x) otherwise "null /*serialize failed*/" 218 | else if x is datetime then 219 | try Expression.Constant(x) otherwise "null /*serialize failed*/" 220 | else if x is datetimezone then 221 | try Expression.Constant(x) otherwise "null /*serialize failed*/" 222 | else if x is duration then 223 | try Expression.Constant(x) otherwise "null /*serialize failed*/" 224 | else if x is function then 225 | try Serialize.Function(x) otherwise "null /*serialize failed*/" 226 | else if x is list then 227 | try Serialize.List(x) otherwise "null /*serialize failed*/" 228 | else if x is logical then 229 | try Expression.Constant(x) otherwise "null /*serialize failed*/" 230 | else if x is null then 231 | try Expression.Constant(x) otherwise "null /*serialize failed*/" 232 | else if x is number then 233 | try Expression.Constant(x) otherwise "null /*serialize failed*/" 234 | else if x is record then 235 | try Serialize.Record(x) otherwise "null /*serialize failed*/" 236 | else if x is table then 237 | try Serialize.Table(x) otherwise "null /*serialize failed*/" 238 | else if x is text then 239 | try Expression.Constant(x) otherwise "null /*serialize failed*/" 240 | else if x is time then 241 | try Expression.Constant(x) otherwise "null /*serialize failed*/" 242 | else if x is type then 243 | try Serialize.Type(x) otherwise "null /*serialize failed*/" 244 | else 245 | "[#_unable_to_serialize_#]" 246 | in 247 | try Serialize(value) otherwise ""; -------------------------------------------------------------------------------- /JamfPro/JamfPro16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/powerbi/46bf1fd74bb3548e52f61454d5a079c7244f00c8/JamfPro/JamfPro16.png -------------------------------------------------------------------------------- /JamfPro/JamfPro20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/powerbi/46bf1fd74bb3548e52f61454d5a079c7244f00c8/JamfPro/JamfPro20.png -------------------------------------------------------------------------------- /JamfPro/JamfPro24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/powerbi/46bf1fd74bb3548e52f61454d5a079c7244f00c8/JamfPro/JamfPro24.png -------------------------------------------------------------------------------- /JamfPro/JamfPro32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/powerbi/46bf1fd74bb3548e52f61454d5a079c7244f00c8/JamfPro/JamfPro32.png -------------------------------------------------------------------------------- /JamfPro/JamfPro40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/powerbi/46bf1fd74bb3548e52f61454d5a079c7244f00c8/JamfPro/JamfPro40.png -------------------------------------------------------------------------------- /JamfPro/JamfPro48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/powerbi/46bf1fd74bb3548e52f61454d5a079c7244f00c8/JamfPro/JamfPro48.png -------------------------------------------------------------------------------- /JamfPro/JamfPro64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamf/powerbi/46bf1fd74bb3548e52f61454d5a079c7244f00c8/JamfPro/JamfPro64.png -------------------------------------------------------------------------------- /JamfPro/MobileDeviceMappings.pqm: -------------------------------------------------------------------------------- 1 | [ 2 | ApiFields = (mappings as list) as list => List.Transform(mappings, each _[api]), 3 | TableFields = (mappings as list ) as list => List.Transform(mappings, each _[mapped]), 4 | 5 | GeneralFields = { 6 | [api = "udid", mapped = "mobileDeviceDetails.udid"], 7 | [api = "displayName", mapped = "name"], 8 | [api = "assetTag", mapped = "mobileDeviceDetails.asset_tag"], 9 | 10 | // TODO: siteId is mapped to the site's ID, but the legacy connector maps it to a record with the site's ID and name 11 | [api = "siteId", mapped = "mobileDeviceDetails.site"], 12 | [api = "lastInventoryUpdateDate", mapped = "mobileDeviceDetails.last_inventory_update_utc"], 13 | [api = "osVersion", mapped = "mobileDeviceDetails.os_version"], 14 | [api = "osBuild", mapped = "mobileDeviceDetails.os_build"], 15 | [api = "ipAddress", mapped = "mobileDeviceDetails.ip_address"], 16 | [api = "managed", mapped = "mobileDeviceDetails.managed"], 17 | [api = "supervised", mapped = "mobileDeviceDetails.supervised"], 18 | [api = "deviceOwnershipType", mapped = "mobileDeviceDetails.device_ownership_level"], 19 | [api = "lastEnrolledDate", mapped = "mobileDeviceDetails.last_enrollment_utc"], 20 | [api = "sharedIpad", mapped = "mobileDeviceDetails.shared"], 21 | [api = "lastBackupDate", mapped = "mobileDeviceDetails.last_backup_time_utc"], 22 | [api = "deviceLocatorServiceEnabled", mapped = "mobileDeviceDetails.device_locator_service_enabled"], 23 | [api = "doNotDisturbEnabled", mapped = "mobileDeviceDetails.do_not_disturb_enabled"], 24 | [api = "cloudBackupEnabled", mapped = "mobileDeviceDetails.cloud_backup_enabled"], 25 | [api = "lastCloudBackupDate", mapped = "mobileDeviceDetails.last_cloud_backup_date_utc"], 26 | [api = "itunesStoreAccountActive", mapped = "mobileDeviceDetails.itunes_store_account_is_active"], 27 | [api = "exchangeDeviceId", mapped = "mobileDeviceDetails.exchange_activesync_device_identifier"], 28 | [api = "tethered", mapped = "mobileDeviceDetails.tethered"] 29 | }, 30 | 31 | HardwareFields = { 32 | [api = "capacityMb", mapped = "mobileDeviceDetails.capacity_mb"], 33 | [api = "availableSpaceMb", mapped = "mobileDeviceDetails.available_mb"], 34 | [api = "usedSpacePercentage", mapped = "mobileDeviceDetails.percentage_used"], 35 | [api = "batteryLevel", mapped = "mobileDeviceDetails.battery_level"], 36 | [api = "serialNumber", mapped = "mobileDeviceDetails.serial_number"], 37 | [api = "wifiMacAddress", mapped = "mobileDeviceDetails.wifi_mac_address"], 38 | [api = "bluetoothMacAddress", mapped = "mobileDeviceDetails.bluetooth_mac_address"], 39 | [api = "modemFirmwareVersion", mapped = "mobileDeviceDetails.modem_firmware"], 40 | [api = "model", mapped = "mobileDeviceDetails.model"], 41 | [api = "modelIdentifier", mapped = "mobileDeviceDetails.model_identifier"], 42 | [api = "modelNumber", mapped = "mobileDeviceDetails.model_number"], 43 | [api = "bluetoothLowEnergyCapable", mapped = "mobileDeviceDetails.ble_capable"] 44 | }, 45 | 46 | UserAndLocationFields = { 47 | [api = "username", mapped = "mobileDeviceDetails.username"], 48 | [api = "realName", mapped = "mobileDeviceDetails.real_name"], 49 | [api = "emailAddress", mapped = "mobileDeviceDetails.email_address"], 50 | [api = "position", mapped = "mobileDeviceDetails.position"], 51 | [api = "phoneNumber", mapped = "mobileDeviceDetails.phone_number"], 52 | [api = "room", mapped = "mobileDeviceDetails.room"], 53 | [api = "building", mapped = "mobileDeviceDetails.building"], 54 | [api = "department", mapped = "mobileDeviceDetails.department"] 55 | }, 56 | 57 | 58 | NetworkFields = { 59 | [api = "cellularTechnology", mapped = "mobileDeviceDetails.cellular_technology"], 60 | [api = "voiceRoamingEnabled", mapped = "mobileDeviceDetails.voice_roaming_enabled"], 61 | [api = "imei", mapped = "mobileDeviceDetails.imei"], 62 | [api = "iccid", mapped = "mobileDeviceDetails.iccid"], 63 | [api = "meid", mapped = "mobileDeviceDetails.meid"], 64 | [api = "carrierSettingsVersion", mapped = "mobileDeviceDetails.carrier_settings_version"], 65 | [api = "currentCarrierNetwork", mapped = "mobileDeviceDetails.current_carrier_network"], 66 | [api = "currentMobileCountryCode", mapped = "mobileDeviceDetails.current_mobile_country_code"], 67 | [api = "currentMobileNetworkCode", mapped = "mobileDeviceDetails.current_mobile_network_code"], 68 | [api = "homeCarrierNetwork", mapped = "mobileDeviceDetails.home_carrier_network"], 69 | [api = "homeMobileCountryCode", mapped = "mobileDeviceDetails.home_mobile_country_code"], 70 | [api = "homeMobileNetworkCode", mapped = "mobileDeviceDetails.home_mobile_network_code"], 71 | [api = "dataRoamingEnabled", mapped = "mobileDeviceDetails.data_roaming_enabled"], 72 | [api = "roaming", mapped = "mobileDeviceDetails.roaming"], 73 | [api = "phoneNumber", mapped = "mobileDeviceDetails.phone"] 74 | }, 75 | 76 | PurchasingFields = { 77 | [api = "purchased", mapped = "mobileDeviceDetails.is_purchased"], 78 | [api = "leased", mapped = "mobileDeviceDetails.is_leased"], 79 | [api = "poNumber", mapped = "mobileDeviceDetails.po_number"], 80 | [api = "vendor", mapped = "mobileDeviceDetails.vendor"], 81 | [api = "appleCareId", mapped = "mobileDeviceDetails.applecare_id"], 82 | [api = "purchasePrice", mapped = "mobileDeviceDetails.purchase_price"], 83 | [api = "purchasingAccount", mapped = "mobileDeviceDetails.purchasing_account"], 84 | [api = "poDate", mapped = "mobileDeviceDetails.po_date_utc"], 85 | [api = "warrantyExpiresDate", mapped = "mobileDeviceDetails.warranty_expires_utc"], 86 | [api = "leaseExpiresDate", mapped = "mobileDeviceDetails.lease_expires_utc"], 87 | [api = "lifeExpectancy", mapped = "mobileDeviceDetails.life_expectancy"], 88 | [api = "purchasingContact", mapped = "mobileDeviceDetails.purchasing_contact"] 89 | }, 90 | 91 | SecurityFields = { 92 | [api = "dataProtected", mapped = "mobileDeviceDetails.data_protection"], 93 | [api = "blockLevelEncryptionCapable", mapped = "mobileDeviceDetails.block_level_encryption_capable"], 94 | [api = "fileLevelEncryptionCapable", mapped = "mobileDeviceDetails.file_level_encryption_capable"], 95 | [api = "passcodePresent", mapped = "mobileDeviceDetails.passcode_present"], 96 | [api = "passcodeCompliant", mapped = "mobileDeviceDetails.passcode_compliant"], 97 | [api = "passcodeCompliantWithProfile", mapped = "mobileDeviceDetails.passcode_compliant_with_profile"], 98 | [api = "hardwareEncryption", mapped = "mobileDeviceDetails.hardware_encryption"], 99 | [api = "activationLockEnabled", mapped = "mobileDeviceDetails.activation_lock_enabled"], 100 | [api = "jailBreakDetected", mapped = "mobileDeviceDetails.jailbreak_detected"], 101 | [api = "passcodeLockGracePeriodEnforcedSeconds", mapped = "mobileDeviceDetails.passcode_lock_grace_period_enforced"], 102 | [api = "lostModeEnabled", mapped = "mobileDeviceDetails.lost_mode_enabled"], 103 | [api = "lostModeMessage", mapped = "mobileDeviceDetails.lost_mode_message"], 104 | [api = "lostModePhoneNumber", mapped = "mobileDeviceDetails.lost_mode_phone"], 105 | [api = "lostModeFootnote", mapped = "mobileDeviceDetails.lost_mode_footnote"], 106 | [api = "lostModeLocation", mapped = "lostModeLocation"], 107 | [api = "lostModeEnabledDate", mapped = "mobileDeviceDetails.lost_mode_enable_issued_utc"] 108 | }, 109 | 110 | LostModeLocationFields = { 111 | [api = "lastLocationUpdate", mapped = "mobileDeviceDetails.lost_location_utc"], 112 | [api = "lostModeLocationHorizontalAccuracyMeters", mapped = "mobileDeviceDetails.lost_location_horizontal_accuracy"], 113 | [api = "lostModeLocationVerticalAccuracyMeters", mapped = "mobileDeviceDetails.lost_location_vertical_accuracy"], 114 | [api = "lostModeLocationAltitudeMeters", mapped = "mobileDeviceDetails.lost_location_altitude"], 115 | [api = "lostModeLocationSpeedMetersPerSecond", mapped = "mobileDeviceDetails.lost_location_speed"], 116 | [api = "lostModeLocationCourseDegrees", mapped = "mobileDeviceDetails.lost_location_course"], 117 | [api = "lostModeLocationTimestamp", mapped = "mobileDeviceDetails.lost_mode_location_timestamp"] 118 | }, 119 | 120 | AppsAndEAsFieldsToDuplicate = { 121 | [original = "name", duplicate = "device_name"], 122 | [original = "name", duplicate = "display_name"], 123 | [original = "model", duplicate = "model_display"], 124 | [original = "model", duplicate = "modelDisplay"] 125 | }, 126 | 127 | FieldsToDuplicate = { 128 | [original = "mobileDeviceDetails.managed", duplicate = "managed"], 129 | [original = "mobileDeviceDetails.udid", duplicate = "udid"], 130 | [original = "mobileDeviceDetails.serial_number", duplicate = "serial_number"], 131 | [original = "mobileDeviceDetails.wifi_mac_address", duplicate = "wifi_mac_address"], 132 | [original = "mobileDeviceDetails.capacity_mb", duplicate = "mobileDeviceDetails.capacity"], 133 | [original = "mobileDeviceDetails.available_mb", duplicate = "mobileDeviceDetails.available"], 134 | [original = "name", duplicate = "device_name"], 135 | [original = "mobileDeviceDetails.model", duplicate = "model"], 136 | [original = "mobileDeviceDetails.model", duplicate = "modelDisplay"], 137 | [original = "mobileDeviceDetails.model", duplicate = "model_display"], 138 | [original = "mobileDeviceDetails.model", duplicate = "mobileDeviceDetails.modelDisplay"], 139 | [original = "mobileDeviceDetails.model", duplicate = "mobileDeviceDetails.model_display"], 140 | [original = "mobileDeviceDetails.model_identifier", duplicate = "model_identifier"], 141 | [original = "mobileDeviceDetails.supervised", duplicate = "supervised"], 142 | [original = "mobileDeviceDetails.username", duplicate = "username"], 143 | [original = "mobileDeviceDetails.real_name", duplicate = "mobileDeviceDetails.realname"], 144 | [original = "mobileDeviceDetails.phone_number", duplicate = "phone_number"], 145 | [original = "mobileDeviceDetails.po_date_utc", duplicate = "mobileDeviceDetails.po_date"], 146 | [original = "mobileDeviceDetails.last_inventory_update_utc", duplicate = "mobileDeviceDetails.last_inventory_update"], 147 | [original = "mobileDeviceDetails.lease_expires_utc", duplicate = "mobileDeviceDetails.lease_expires"], 148 | [original = "mobileDeviceDetails.warranty_expires_utc", duplicate = "mobileDeviceDetails.warranty_expires"] 149 | }, 150 | 151 | General = [api = ApiFields(GeneralFields), mapped = TableFields(GeneralFields)], 152 | Hardware = [api = ApiFields(HardwareFields), mapped = TableFields(HardwareFields)], 153 | UserAndLocation = [api = ApiFields(UserAndLocationFields), mapped = TableFields(UserAndLocationFields)], 154 | Network = [api = ApiFields(NetworkFields), mapped = TableFields(NetworkFields)], 155 | Purchasing = [api = ApiFields(PurchasingFields), mapped = TableFields(PurchasingFields)], 156 | Security = [api = ApiFields(SecurityFields), mapped = TableFields(SecurityFields)], 157 | LostModeLocation = [api = ApiFields(LostModeLocationFields), mapped = TableFields(LostModeLocationFields)], 158 | 159 | /** 160 | * These date/time fields are both mapped directly and converted to an `_epoch` column 161 | * format for compatibility with the legacy model. See the `copyUtcColumnsToEpoch` 162 | * function for detail. 163 | */ 164 | UtcColumnNames = { 165 | "mobileDeviceDetails.last_inventory_update_utc", 166 | "mobileDeviceDetails.last_enrollment_utc", 167 | "mobileDeviceDetails.last_backup_time_utc", 168 | "mobileDeviceDetails.last_cloud_backup_date_utc", 169 | "mobileDeviceDetails.initial_entry_date_utc", 170 | "mobileDeviceDetails.lease_expires_utc", 171 | "mobileDeviceDetails.po_date_utc", 172 | "mobileDeviceDetails.warranty_expires_utc", 173 | "mobileDeviceDetails.lost_mode_enable_issued_utc", 174 | "mobileDeviceDetails.lost_location_utc" 175 | }, 176 | 177 | /** 178 | * Fields that did not have a clear analog to the Jamf Pro API data model. To ensure 179 | * backward compatibility with existing datasets and visualizations, these fields are 180 | * added with `null` values for all rows. See the `addUnmappedColumns` function for 181 | * detail. 182 | */ 183 | UnmappedFields = { 184 | "mobileDeviceDetails.initial_entry_date_utc", 185 | "mobileDeviceDetails.location_services_enabled", 186 | "mobileDeviceDetails.attachments", 187 | "mobileDeviceDetails.lost_mode_enforced", 188 | "mobileDeviceDetails.lost_location_latitude", 189 | "mobileDeviceDetails.lost_location_longitude" 190 | } 191 | ] 192 | -------------------------------------------------------------------------------- /JamfPro/resources.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | Jamf Pro 122 | 123 | 124 | URL 125 | 126 | 127 | Jamf Pro URL 128 | 129 | 130 | The URL for your Jamf Pro instance 131 | 132 | 133 | Import data from Jamf Pro 134 | 135 | 136 | Jamf Pro 137 | 138 | 139 | Jamf Pro 140 | 141 | 142 | Username Password 143 | 144 | 145 | API Client 146 | 147 | 148 | Enter as client_id:client_secret 149 | 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # powerbi 2 | Jamf Power BI Integration - Custom Connector 3 | 4 | This custom connector workflow requires Jamf Pro 10.48 or higher. 5 | 6 | Refer to the [install guide pdf](./Jamf%20PowerBI%20Basic%20Installation%20Instructions%20for%20PowerBI%20Desktop.pdf) 7 | for installation and conenction settings. 8 | 9 | ### Developer set-up 10 | VS Code on Windows is the recommended development environment. Note that the Power Query SDK 11 | is required and is only available on Windows. 12 | 13 | 1. On Windows, launch Visual Studio Code. 14 | 2. Install the Power Query SDK extension from the extensions marketplace. 15 | 3. Open the folder `powerbi/JamfPro` in VS Code. 16 | 4. Select Setup workspace… from the Power Query SDK tools. 17 | 5. Go to Terminal → Run Build Task. 18 | 19 | The [`.vscode`](JamfPro/.vscode) directory includes the Power Query project environment settings and a 20 | default build task to assemble a `.mez` file and copy it to the repository's root directory. That resulting 21 | file can be placed in your 'Custom Connector' folder for use in Power BI Desktop. 22 | 23 | ### Branching strategy and contributions 24 | * `main` is protected and reflects the latest published version of the connector. 25 | * `develop` is the default branch for development. Any pull requests should be made against this branch. 26 | 27 | ### CI 28 | None yet, but a GitHub Action will be added to automate the build process. Until then, keep the `.mez` 29 | file in the repository root directory up-to-date with any code changes. --------------------------------------------------------------------------------