diff --git a/src/Api.ps1 b/src/Api.ps1 index 5873ad3..9d62e3c 100644 --- a/src/Api.ps1 +++ b/src/Api.ps1 @@ -70,6 +70,18 @@ function Invoke-ApiHandler { return Get-UserMemberOfEndpoint -UserId $matches[1] } + if ($Method -eq "GET" -and $Path -eq "/api/devices") { + return Search-DevicesEndpoint -Query $Query + } + + if ($Method -eq "GET" -and $Path -match "^/api/devices/([^/]+)$") { + return Get-DeviceEndpoint -DeviceId $matches[1] + } + + if ($Method -eq "POST" -and $Path -match "^/api/devices/([^/]+)/action$") { + return Invoke-DeviceActionEndpoint -DeviceId $matches[1] -Body $Body + } + if ($Method -eq "GET" -and $Path -match "^/api/apps/([^/]+)/details$") { return Get-AppDetailsEndpoint -AppId $matches[1] } @@ -1520,6 +1532,107 @@ function Get-UserMemberOfEndpoint { return @{ groups = $groups; count = $groups.Count } } +# ============================================================ +# Devices +# ============================================================ + +function Search-DevicesEndpoint { + param($Query) + $err = Test-Connected + if ($err) { return $err } + + $q = [string]$Query.q + $os = [string]$Query.os + $compliance = [string]$Query.compliance + $top = 100 + + $filters = @() + if ($q -and $q.Length -ge 2) { + $qEnc = [uri]::EscapeDataString($q) + $filters += "(startswith(deviceName,'$q') or startswith(userDisplayName,'$q') or startswith(serialNumber,'$q'))" + } + if ($os) { $filters += "operatingSystem eq '$os'" } + if ($compliance) { $filters += "complianceState eq '$compliance'" } + + $filterStr = if ($filters.Count -gt 0) { '$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) + '&' } else { '' } + $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,managementState,serialNumber,model,manufacturer,enrolledDateTime' + $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?${filterStr}`$select=$select&`$top=$top&`$orderby=deviceName" + + $items = @() + $resp = Invoke-MgGraphRequestRetry -Uri $uri -Method GET + foreach ($d in $resp.value) { + $items += [pscustomobject]@{ + Id = $d.id + DeviceName = $d.deviceName + UserDisplayName = $d.userDisplayName + UserPrincipalName= $d.userPrincipalName + OS = $d.operatingSystem + OSVersion = $d.osVersion + ComplianceState = $d.complianceState + LastSync = $d.lastSyncDateTime + ManagementState = $d.managementState + SerialNumber = $d.serialNumber + Model = $d.model + Manufacturer = $d.manufacturer + EnrolledDateTime = $d.enrolledDateTime + } + } + return @{ items = $items; count = $items.Count } +} + +function Get-DeviceEndpoint { + param($DeviceId) + $err = Test-Connected + if ($err) { return $err } + + $select = 'id,deviceName,userDisplayName,userPrincipalName,operatingSystem,osVersion,complianceState,lastSyncDateTime,managementState,serialNumber,model,manufacturer,enrolledDateTime,imei,wiFiMacAddress,azureADDeviceId,joinType,deviceEnrollmentType,managedDeviceOwnerType,totalStorageSpaceInBytes,freeStorageSpaceInBytes' + $d = Invoke-MgGraphRequestRetry -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId`?`$select=$select" -Method GET + + $totalGB = if ($d.totalStorageSpaceInBytes) { [math]::Round($d.totalStorageSpaceInBytes / 1GB, 1) } else { $null } + $freeGB = if ($d.freeStorageSpaceInBytes) { [math]::Round($d.freeStorageSpaceInBytes / 1GB, 1) } else { $null } + + return @{ + Id = $d.id + DeviceName = $d.deviceName + UserDisplayName = $d.userDisplayName + UserPrincipalName= $d.userPrincipalName + OS = $d.operatingSystem + OSVersion = $d.osVersion + ComplianceState = $d.complianceState + LastSync = $d.lastSyncDateTime + ManagementState = $d.managementState + SerialNumber = $d.serialNumber + Model = $d.model + Manufacturer = $d.manufacturer + EnrolledDateTime = $d.enrolledDateTime + Imei = $d.imei + WiFiMac = $d.wiFiMacAddress + AzureADDeviceId = $d.azureADDeviceId + JoinType = $d.joinType + EnrollmentType = $d.deviceEnrollmentType + OwnerType = $d.managedDeviceOwnerType + TotalStorageGB = $totalGB + FreeStorageGB = $freeGB + } +} + +function Invoke-DeviceActionEndpoint { + param($DeviceId, $Body) + $err = Test-Connected + if ($err) { return $err } + + $action = [string]$Body.action + $allowed = @('syncDevice','rebootNow','remoteLock','collectDiagnostics','rotateBitLockerKeys','retire') + if ($action -notin $allowed) { + return @{ statusCode = 400; error = "Unbekannte Aktion: $action" } + } + + $uri = "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices/$DeviceId/$action" + $bodyJson = '{}' + Invoke-MgGraphRequestRetry -Uri $uri -Method POST -Body $bodyJson -ContentType 'application/json' | Out-Null + return @{ success = $true; action = $action } +} + # ============================================================ # Apps # ============================================================ diff --git a/www/app.js b/www/app.js index 44dc220..d72707d 100644 --- a/www/app.js +++ b/www/app.js @@ -4390,6 +4390,7 @@ function switchMainView(view) { const layout = document.getElementById('layout'); const groupView = document.getElementById('groupMgmtView'); const reportView = document.getElementById('appReportView'); + const devView = document.getElementById('devView'); document.querySelectorAll('#topbarNav .nav-tab').forEach(t => { t.classList.toggle('active', t.dataset.view === view); t.setAttribute('aria-selected', t.dataset.view === view ? 'true' : 'false'); @@ -4397,18 +4398,20 @@ function switchMainView(view) { layout .classList.toggle('hidden', view !== 'apps'); groupView .classList.toggle('hidden', view !== 'groups'); reportView.classList.toggle('hidden', view !== 'report'); + devView .classList.toggle('hidden', view !== 'devices'); } document.querySelectorAll('#topbarNav .nav-tab').forEach(btn => { btn.addEventListener('click', () => { const view = btn.dataset.view; - if ((view === 'groups' || view === 'report') && !State.connected) { + if ((view === 'groups' || view === 'report' || view === 'devices') && !State.connected) { toast('Bitte zuerst verbinden.', 'warn'); return; } switchMainView(view); if (view === 'groups') openGroupExportModal(); if (view === 'report' && ReportState.items.length === 0) loadAppReport(); + if (view === 'devices') { devLoadAll(); setTimeout(() => document.getElementById('devSearch').focus(), 80); } }); }); @@ -5222,3 +5225,239 @@ function notifyCsvImportGroupSelected(groupId, groupName) { if (CsvImport.resolved.length > 0) renderCsvPreview(); else updateCsvImportNote(); } + +// ============================================================ +// Geräte-Tab +// ============================================================ +const DevState = { + items: [], + filtered: [], + selected: null, + sortCol: 'deviceName', + sortAsc: true, + loaded: false +}; + +async function devLoadAll() { + if (DevState.loaded) return; + devShowLoading(true); + try { + const data = await api('/api/devices'); + DevState.items = data.items || []; + DevState.loaded = true; + devApplyFilters(); + } catch (e) { + toast('Geräte konnten nicht geladen werden: ' + e, 'error'); + } finally { + devShowLoading(false); + } +} + +function devShowLoading(on) { + document.getElementById('devLoading').classList.toggle('hidden', !on); + document.getElementById('devEmpty').classList.add('hidden'); +} + +function devApplyFilters() { + const q = document.getElementById('devSearch').value.toLowerCase(); + const os = document.getElementById('devOsFilter').value; + const cmp = document.getElementById('devComplianceFilter').value; + + DevState.filtered = DevState.items.filter(d => { + if (os && d.OS !== os) return false; + if (cmp && d.ComplianceState !== cmp) return false; + if (q) { + const hay = `${d.DeviceName} ${d.UserDisplayName} ${d.UserPrincipalName} ${d.SerialNumber}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); + + devSort(); +} + +function devSort() { + const col = DevState.sortCol; + const asc = DevState.sortAsc; + DevState.filtered.sort((a, b) => { + const av = (a[col] || '').toString().toLowerCase(); + const bv = (b[col] || '').toString().toLowerCase(); + return asc ? av.localeCompare(bv) : bv.localeCompare(av); + }); + devRender(); +} + +function devRender() { + const tbody = document.getElementById('devTableBody'); + const empty = document.getElementById('devEmpty'); + document.getElementById('devCount').textContent = DevState.filtered.length; + + if (DevState.filtered.length === 0) { + tbody.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + empty.classList.add('hidden'); + + // Sort-Icons + document.querySelectorAll('#devTable th.sortable').forEach(th => { + const ico = th.querySelector('.sort-ico'); + if (th.dataset.col === DevState.sortCol) { + ico.textContent = DevState.sortAsc ? ' ▲' : ' ▼'; + } else { + ico.textContent = ''; + } + }); + + let html = ''; + for (const d of DevState.filtered) { + const sync = d.LastSync ? new Date(d.LastSync).toLocaleDateString('de-DE') : '—'; + const cmpCls = d.ComplianceState === 'compliant' ? 'dev-badge-ok' + : d.ComplianceState === 'noncompliant' ? 'dev-badge-err' : 'dev-badge-unk'; + const sel = DevState.selected && DevState.selected.Id === d.Id ? ' class="dev-row-selected"' : ''; + html += `