Skip to content

Instantly share code, notes, and snippets.

@fred
Last active March 24, 2025 05:08
Show Gist options
  • Save fred/ddf413ec58690f04db9c9b69769996cc to your computer and use it in GitHub Desktop.
Save fred/ddf413ec58690f04db9c9b69769996cc to your computer and use it in GitHub Desktop.
Kandji Export Script
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment