require 'json' require 'net/http' require 'uri' require 'csv' require 'logger' class KandjiDeviceReport BATCH_SIZE = 300 def initialize(api_token, sub_domain) @api_token = api_token @sub_domain = sub_domain @base_url = "https://#{@sub_domain}.api.kandji.io/api/v1" @logger = Logger.new($stdout) @logger.level = Logger::INFO end def generate_report(output_file = 'device_report.csv') @logger.info("Starting device report generation...") CSV.open(output_file, 'w') do |csv| csv << [ 'Device ID', 'Serial Number', 'Device Name', 'Battery Health', 'State of Charge', 'Total Disk Space (GB)', 'Free Disk Space (GB)', 'Used Disk Space (%)', 'ICloudDriveDesktop', 'ICloudDriveDocuments', 'ICloudDriveEnabled', 'ICloudDriveFirstSyncDownComplete', 'ICloudLoggedIn', 'Default Browser', 'Locale', 'Region' ] offset = 0 loop do devices = fetch_devices(offset) puts "devices_count: #{devices.size}" break if devices.empty? process_devices(devices, csv) offset += BATCH_SIZE @logger.info("Processed #{offset} devices...") end end @logger.info("Report generation completed: #{output_file}") rescue StandardError => e @logger.error("Error generating report: #{e.message}") @logger.error(e.backtrace.join("\n")) raise end private def fetch_devices(offset) url = "#{@base_url}/devices" url += "?offset=#{offset}" if offset > 0 make_request(url) end def fetch_device_status(device_id) make_request("#{@base_url}/devices/#{device_id}/status") end def fetch_device_details(device_id) make_request("#{@base_url}/devices/#{device_id}/details") end def make_request(url) uri = URI(url) request = Net::HTTP::Get.new(uri) request['Authorization'] = "Bearer #{@api_token}" response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| http.request(request) end handle_response(response) rescue StandardError => e @logger.error("API request failed for #{url}: #{e.message}") raise end def handle_response(response) case response when Net::HTTPSuccess JSON.parse(response.body) else @logger.error("API request failed with status #{response.code}: #{response.body}") raise "API request failed: #{response.message}" end end def process_devices(devices, csv) devices.each do |device| device_id = device['device_id'] @logger.info("processing: #{device_id}") # Fetch both status and details from Library Items status = fetch_device_status(device_id) details = fetch_device_details(device_id) battery_health = extract_battery_health(status) state_of_charge = extract_state_of_charge(status) disk_info = extract_disk_info(details) icloud_info = extract_icloud_info(status) default_browser = extract_default_browser_info(status) default_locale = extract_locale_info(status) csv_row = [ device_id, device['serial_number'], device['device_name'], battery_health, state_of_charge, disk_info[:total_gb], disk_info[:free_gb], disk_info[:used_percentage], icloud_info[:desktop].to_s, icloud_info[:documents].to_s, icloud_info[:drive_enabled].to_s, icloud_info[:drive_sync_done].to_s, icloud_info[:icloud_logged_in].to_s, default_browser.to_s, default_locale[:locale], default_locale[:region] ] csv << csv_row @logger.debug("#{status}") @logger.info("Processed device: #{csv_row}") end end def extract_battery_health(status) if match = status['library_items'].to_s.match(/Maximum Capacity: ([0-9]{2,3})\%/) match[1] end end def extract_state_of_charge(status) if match = status['library_items'].to_s.match(/State of Charge \(\%\)\: ([0-9]{2,3})/) match[1] end end def extract_default_browser_info(status) if match = status['library_items'].to_s.match(/Default Browser\: ([a-zA-Z.]+)/) match[1] end end def extract_locale_info(status) locale_match = status['library_items'].to_s.match(/AppleLocale:\s*([a-z_@=]+)/i) region_match = status['library_items'].to_s.match(/Country\/Region:\s*([a-z]{2})/i) locale = locale_match ? locale_match[1] : 'unknown' region = region_match ? region_match[1] : 'Unknown' return { locale: locale, region: region } end def extract_icloud_info(status) icloud_info = { desktop: nil, documents: nil, drive_enabled: nil, drive_sync_done: nil, icloud_logged_in: nil } if s = status.to_s.match(/FXICloudDriveDesktop \= ([0-1]{1})/) icloud_info[:desktop] = s[1] end if s = status.to_s.match(/FXICloudDriveDocuments \= ([0-1]{1})/) icloud_info[:documents] = s[1] end if s = status.to_s.match(/FXICloudDriveEnabled \= ([0-1]{1})/) icloud_info[:drive_enabled] = s[1] end if s = status.to_s.match(/FXICloudDriveFirstSyncDownComplete \= ([0-1]{1})/) icloud_info[:drive_sync_done] = s[1] end if s = status.to_s.match(/FXICloudLoggedIn \= ([0-1]{1})/) icloud_info[:icloud_logged_in] = s[1] end icloud_info end def find_right_disc(volumes) volumes.find { |volume| volume['encrypted'] == 'Yes' && volume['capacity'].to_f > 100} || volumes.first end def extract_disk_info(details) disk_info = { total_gb: nil, free_gb: nil, used_percentage: nil } volumes = details["volumes"] if !volumes.empty? && (disc = find_right_disc(volumes)) && !disc.nil? disk_info[:total_gb] = disc['capacity'].to_s disk_info[:free_gb] = disc['available'].to_s disk_info[:used_percentage] = disc['percent_used'].to_s end disk_info end end # Usage example: begin api_token = ENV['KANDJI_API_TOKEN'] sub_domain = ENV['KANDJI_SUBDOMAIN'] if api_token.nil? || sub_domain.nil? raise "Please set both KANDJI_API_TOKEN and KANDJI_SUBDOMAIN environment variables" end reporter = KandjiDeviceReport.new(api_token, sub_domain) reporter.generate_report('device_report.csv') rescue StandardError => e puts "Failed to generate report: #{e.message}" exit 1 end