Sync-SPOServiceDeskSiteList
Requirement
The service desk needs visibility into SharePoint sites they support, but they don't have access to the SharePoint Admin Center (Active Sites), which is the only place this data currently lives. Teams-connected sites are out of scope — those are managed and referenced through Teams itself, so the service desk only needs the subset of sites that are not Teams-connected (and not Communication sites, which also fall outside their remit).
Solution
Sync-SPOServiceDeskSiteList.ps1 syncs the SharePoint Admin Center's site list into a custom SharePoint list the service desk already has access to. It pulls the full tenant site list, filters out Teams-connected and Communication sites, enriches each remaining site with usage (storage) and genuine last-activity data (reads + writes, not just edits — sourced via Microsoft Graph's usage reports, since that's the only source that captures "someone viewed this" and not just "someone edited this"), and syncs the result into a SharePoint list the service desk already has access to.
Key behaviors:
- Upsert, not overwrite — sites already in the list get their data refreshed, but a Comments column the service desk fills in manually is never touched by the script
- Soft removal — sites no longer in scope (deleted, or now Teams-connected) get flagged
Status = Removedrather than being deleted from the list, so there's an audit trail - Status derived from activity — Active/Inactive computed from last-activity date against a 12-month threshold, so the service desk can see at a glance which sites are stale
- Runs on a weekly schedule, currently being ported from a manually-run dev script into an Azure Automation runbook for unattended execution
Notable technical constraints worth knowing about: the Graph usage report's own Site URL field is unreliable (documented Microsoft bug), so URLs are resolved by matching Site ID instead; Teams-connected detection currently uses the classic SPO Management Shell module (IsTeamsConnected), which doesn't run in the same PowerShell runtime as PnP.PowerShell — a Graph-based alternative is being validated to remove that dependency before the runbook is finalized.
Purpose
- Enumerates every site in the tenant via
Get-SPOSite -Limit All, then filters out sites whereIsTeamsConnectedorIsTeamsChannelConnectedis true, and Communication sites (SITEPAGEPUBLISHING*template) - Requests Microsoft Graph's
getSharePointSiteUsageDetailreport viaInvoke-PnPGraphMethod, reusing PnP's own connection/token rather than adding a separate Microsoft.Graph module dependency - Resolves each remaining site's Graph Site ID directly (
sites/{hostname}:{path}?$select=id) and joins it against the usage report by ID, since the report's own Site URL column is unreliable - Classifies each site as Active or Inactive against a configurable inactivity threshold (default 12 months), or Unknown if the Graph Site ID lookup failed or the site had no match in the usage report
- Exports a timestamped CSV of the full result set alongside the list sync, as a local record of each run
- Upserts every result into the target SharePoint list, keyed on Site URL — updates existing rows, adds new ones, and leaves the service desk's own Comments column untouched
- Flags list rows no longer present in the current run's results as
SiteStatus = Removedinstead of deleting them, preserving history and any comments already recorded
Scope
- This is the validated manual/interactive version, run from a workstation — a scheduled Azure Automation runbook version is in progress, primarily to replace the classic SPO Management Shell dependency described below with a Graph-based equivalent that can run unattended
- Teams-connected detection depends on
Get-SPOSite'sIsTeamsConnected/IsTeamsChannelConnectedproperties from the classic SPO Management Shell module, loaded via-UseWindowsPowerShellcompatibility mode —Get-PnPTenantSitestill doesn't expose these (open PnP issue #2636) - Last Activity Date combines reads and writes and can't distinguish the two — see Get-SPOSiteUsageReport for the fuller writeup on what this figure does and doesn't tell you
- Site URL resolution costs one extra Graph call per site — fine for a weekly batch of tens of sites, would need batching for a much larger non-Teams-connected subset
- The Comments column is treated as service-desk-owned and is never written to by this script, on any code path, including for newly added rows
Prerequisites
- PnP.PowerShell 3.x module installed
- Microsoft.Online.SharePoint.PowerShell (classic SPO Management Shell) available and loadable via
-UseWindowsPowerShell— needed solely for the Teams-connected flags - SharePoint Administrator or Global Administrator permissions against the tenant
- Reports.Read.All Graph permission — either via delegated admin consent on first run, or granted to the app registration in advance
- Write access to the target SharePoint list, with columns matching
Title,SiteUrl,UsageGB,LastActivity,SiteOwner,SiteStatus, andComments
PowerShell Script
<#
.SYNOPSIS
Service Desk Site Inventory - syncs non-Teams-connected, non-Communication
SharePoint sites (with usage and last-activity data) into a SharePoint list
the service desk has access to.
.DESCRIPTION
Manual/interactive version, run from a workstation - validated end-to-end
against SiteName. Pulls the tenant's full site list, filters out
Teams-connected and Communication sites, gets each remaining site with
storage usage and genuine last-activity data, then upserts the result into
a SharePoint list.
List sync behavior:
- Sites already in the list are updated in place. The Comments column,
which the service desk fills in manually, is never touched.
- Sites no longer in scope (deleted, or now Teams-connected) are flagged
Status = "Removed" rather than being deleted from the list, so there's
an audit trail and no accidental loss of service desk annotations.
- New sites are added fresh.
Requires two modules side by side:
- PnP.PowerShell 3.x
-> everything: site enumeration, the Graph usage report via
Invoke-PnPGraphMethod (reuses PnP's own connection/token, so no
separate Microsoft.Graph module and no MSAL clash - same approach
as Get-SPOSiteUsageReport.ps1), and all list read/write operations.
- Microsoft.Online.SharePoint.PowerShell (classic SPO Management Shell),
loaded via -UseWindowsPowerShell compatibility mode
-> ONLY used for Get-SPOSite's IsTeamsConnected / IsTeamsChannelConnected
properties, which Get-PnPTenantSite still doesn't expose (open PnP
issue #2636). Everything else stays on PnP.
Last Activity Date comes from Microsoft Graph's getSharePointSiteUsageDetail
report - reads + writes combined, genuine history, not limited to a rolling
window (see Get-SPOSiteUsageReport.ps1 for the fuller writeup on this). Site
URLs are resolved via a separate per-site Graph Site ID lookup, working
around a known Microsoft bug where the report's own Site URL column is
unreliable.
.NOTES
Stage: Validated manual/interactive version, run from a workstation.
Target: Site (swap $AdminUrl and $ListSiteUrl before running against
anything else)
#>
#region Config - EDIT THESE BEFORE RUNNING
$AdminUrl = "https://tenantName-admin.sharepoint.com" # <-- confirm this is right for tenant
$ClientId = ""
$InactivityThresholdMonths = 12
$UsagePeriod = "D180" # only affects PageViewCount/VisitedPageCount, not LastActivityDate
$ListSiteUrl = "https://tenantName.sharepoint.com/sites/siteName" # <-- confirm this matches Your LIST SITE AND NAME
$ListName = "listName"
#endregion
# If this ever throws "module not found" on a different machine,
# the fix is a one-time manual install in real Windows PowerShell, not a
# scripted re-install.
try {
Import-Module Microsoft.Online.SharePoint.PowerShell -UseWindowsPowerShell -ErrorAction Stop
}
catch {
Write-Host "Could not load Microsoft.Online.SharePoint.PowerShell: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Try: powershell.exe -NoProfile -Command `"Get-Module -ListAvailable Microsoft.Online.SharePoint.PowerShell`" to confirm it's still visible from this session." -ForegroundColor Yellow
exit 1
}
#endregion
#region Connect
Write-Host "Connecting to SharePoint Online (PnP)..." -ForegroundColor Cyan
Connect-PnPOnline -Url $AdminUrl -Interactive -ClientId $ClientId
Write-Host "Connecting to SharePoint Online (classic - Teams-connected flag only)..." -ForegroundColor Cyan
Connect-SPOService -Url $AdminUrl
#endregion
$InactivityCutoff = (Get-Date).AddMonths(-$InactivityThresholdMonths)
Write-Host "`nPulling all sites from SPO (this can take a minute on large tenants)..." -ForegroundColor Cyan
$AllSites = Get-SPOSite -Limit All
Write-Host "Filtering out Teams-connected sites and Communication sites..." -ForegroundColor Cyan
$NonTeamsSites = $AllSites | Where-Object {
-not $_.IsTeamsConnected -and -not $_.IsTeamsChannelConnected -and $_.Template -notlike "SITEPAGEPUBLISHING*"
}
Write-Host "$($NonTeamsSites.Count) non-Teams-connected sites found (of $($AllSites.Count) total)`n" -ForegroundColor Green
Write-Host "Pulling Graph site usage report (Last Activity Date, reads + writes)..." -ForegroundColor Cyan
try {
$rawCsv = Invoke-PnPGraphMethod -Url "reports/getSharePointSiteUsageDetail(period='$UsagePeriod')" -Method Get -Raw -ErrorAction Stop
}
catch {
Write-Host "Failed to retrieve the usage report: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "Usually means the connecting account is missing Reports.Read.All, or admin consent hasn't been granted yet." -ForegroundColor Yellow
exit 1
}
$UsageData = $rawCsv | ConvertFrom-Csv
Write-Host "Retrieved usage data for $($UsageData.Count) sites from the Graph report." -ForegroundColor Cyan
# KNOWN MICROSOFT BUG (advisory SP676147, unresolved since 2023): the "Site URL"
# column in this report comes back empty even with anonymization off. Site Id IS
# populated correctly though, so we resolve each site's Graph Site ID separately
# and join on that instead of URL. Adds one Graph call per site - fine for a
# weekly batch of ~50 sites, would need batching for a much larger tenant.
$UsageLookupById = @{}
foreach ($row in $UsageData) {
if ($row.'Site Id') { $UsageLookupById[$row.'Site Id'] = $row }
}
$Results = foreach ($Site in $NonTeamsSites) {
$Uri = [Uri]$Site.Url
$HostName = $Uri.Host
$SitePath = $Uri.AbsolutePath.TrimEnd('/')
$SiteId = $null
try {
$GraphSite = Invoke-PnPGraphMethod -Url "sites/${HostName}:${SitePath}?`$select=id" -Method Get -ErrorAction Stop
# Graph's composite id is "hostname,siteIdGuid,webIdGuid" - the usage
# report's Site Id column is just the middle GUID.
$SiteId = ($GraphSite.id -split ',')[1]
}
catch {
Write-Warning " Could not resolve Graph Site ID for $($Site.Url): $($_.Exception.Message)"
}
$UsageRow = if ($SiteId) { $UsageLookupById[$SiteId] } else { $null }
$LastActivityDate = $null
if ($UsageRow -and $UsageRow.'Last Activity Date' -and $UsageRow.'Last Activity Date' -ne '') {
$LastActivityDate = [datetime]$UsageRow.'Last Activity Date'
}
$Status = if ($LastActivityDate -and $LastActivityDate -ge $InactivityCutoff) {
"Active"
} elseif ($LastActivityDate) {
"Inactive"
} else {
"Unknown" # Graph Site ID lookup failed, or no match in usage report - review manually
}
[PSCustomObject]@{
Url = $Site.Url
Title = $Site.Title
UsageGB = [math]::Round($Site.StorageUsageCurrent / 1024, 2) # StorageUsageCurrent is in MB
LastActivity = $LastActivityDate
Owner = $Site.Owner
Comments = "" # manual field for service desk - stays blank here, preserved on future upsert
Status = $Status
}
}
Write-Host "`n--- Results ---`n" -ForegroundColor Cyan
$Results | Sort-Object LastActivity | Format-Table -AutoSize
$ExportPath = Join-Path $PSScriptRoot "ServiceDeskSiteInventory_$(Get-Date -Format 'yyyyMMdd').csv"
$Results | Export-Csv -Path $ExportPath -NoTypeInformation
Write-Host "`nExported to $ExportPath" -ForegroundColor Green
$unknownCount = ($Results | Where-Object { $_.Status -eq "Unknown" }).Count
if ($unknownCount -gt 0) {
Write-Host "$unknownCount site(s) had no match in the usage report - worth checking manually, these could be very new sites or ones with genuinely zero recorded activity." -ForegroundColor Yellow
}
#region Write to the SharePoint list (upsert - keyed on Site URL)
# Sites already in the list: fields are updated EXCEPT Comments, which the service
# desk owns and this script never touches.
# Sites in the list but no longer in $Results (removed, or now Teams-connected):
# SiteStatus is set to "Removed" rather than deleting the row.
# Sites not yet in the list: added fresh, Comments left blank.
Write-Host "`nConnecting to $ListSiteUrl to sync the list..." -ForegroundColor Cyan
Connect-PnPOnline -Url $ListSiteUrl -Interactive -ClientId $ClientId
Write-Host "Reading existing list items..." -ForegroundColor Cyan
$ExistingItems = Get-PnPListItem -List $ListName -PageSize 500
$ExistingLookup = @{}
foreach ($item in $ExistingItems) {
$key = $item["SiteUrl"]
if ($key) { $ExistingLookup[$key] = $item }
}
$CurrentUrls = @{}
$addedCount = 0
$updatedCount = 0
foreach ($Row in $Results) {
$CurrentUrls[$Row.Url] = $true
$fieldValues = @{
Title = $Row.Title
SiteUrl = $Row.Url
UsageGB = $Row.UsageGB
LastActivity = $Row.LastActivity
SiteOwner = $Row.Owner
SiteStatus = $Row.Status
# Comments intentionally omitted - never overwritten by this script
}
if ($ExistingLookup.ContainsKey($Row.Url)) {
try {
Set-PnPListItem -List $ListName -Identity $ExistingLookup[$Row.Url].Id -Values $fieldValues -ErrorAction Stop | Out-Null
$updatedCount++
}
catch {
# Item existed when the list was read moments ago but is gone now
# (manual deletion, or SharePoint consistency lag on a bulk delete).
# Fall back to adding it fresh rather than failing the whole run.
Add-PnPListItem -List $ListName -Values $fieldValues | Out-Null
$addedCount++
}
}
else {
Add-PnPListItem -List $ListName -Values $fieldValues | Out-Null
$addedCount++
}
}
# Flag rows that exist in the list but weren't in this run's results
$removedCount = 0
foreach ($url in $ExistingLookup.Keys) {
if (-not $CurrentUrls.ContainsKey($url)) {
$existingStatus = $ExistingLookup[$url]["SiteStatus"]
if ($existingStatus -ne "Removed") {
try {
Set-PnPListItem -List $ListName -Identity $ExistingLookup[$url].Id -Values @{ SiteStatus = "Removed" } -ErrorAction Stop | Out-Null
$removedCount++
}
catch {
# Already gone (deleted manually, or consistency lag) - nothing to flag.
}
}
}
}
Write-Host "`nList sync complete: $addedCount added, $updatedCount updated, $removedCount flagged as Removed." -ForegroundColor Green
#endregion
Usage Notes
$AdminUrl— SharePoint admin centre URL for the tenant being audited; confirm before running$ClientId— Azure AD app registration client ID, or leave blank to use interactive login without a registered app$InactivityThresholdMonths— months since Last Activity Date before a site is classified Inactive rather than Active (default 12)$UsagePeriod— D7/D30/D90/D180; only affects the report's own PageViewCount/VisitedPageCount fields, not Last Activity Date$ListSiteUrl/$ListName— the site and list the service desk reads from; confirm both match the actual target before running- The classic
Microsoft.Online.SharePoint.PowerShellmodule is imported via-UseWindowsPowerShell— if that import fails on a new machine, the fix is a one-time manual install in real Windows PowerShell, not a scripted re-install - Every run also writes a timestamped CSV to the script's own folder, independent of the list sync, as a local record
- Rows classified
Unknownmean the Graph Site ID lookup failed or the site had no match in the usage report — worth checking manually rather than trusting the Active/Inactive split blindly - The Comments field is never written by this script on any code path — new rows get it blank, existing rows keep whatever the service desk already entered
- This is the manual/interactive version; a scheduled Azure Automation runbook version is in progress, pending a Graph-based replacement for the classic module's Teams-connected flags
Related
- Get-SPOSiteUsageReport — this script reuses its Graph usage report approach (same PnP-only connection, same Site ID resolution to work around the report's blank Site URL bug) for a service-desk-facing, filtered subset of sites
- Sync-SPOServiceDeskSiteList-Graph — same end result via a Graph-only Teams-connected check instead of the classic SPO module; use that version when this one's
-UseWindowsPowerShelldependency isn't available (e.g. an unattended Azure Automation runbook)