convert to gitea
This commit is contained in:
411
Backup-SPO-PoshRSJob.ps1
Normal file
411
Backup-SPO-PoshRSJob.ps1
Normal file
@ -0,0 +1,411 @@
|
||||
# ==================================================
|
||||
# SharePoint 백업 스크립트
|
||||
# ==================================================
|
||||
|
||||
using namespace System.Collections.Concurrent
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
장시간 실행에도 안정적인 SharePoint Online 병렬 백업 스크립트.
|
||||
|
||||
.DESCRIPTION
|
||||
- 각 병렬 다운로드 작업(Job)이 시작 직전에 인증 토큰을 자동으로 갱신하여
|
||||
초대용량 파일 다운로드 등으로 인해 발생하는 세션 만료 문제 해결.
|
||||
- 지능형 재시도 기능으로 로컬에 이미 있거나 불완전한 파일을 자동으로 처리.
|
||||
(정상 파일: 건너뛰기, 불완전한 파일: 덮어쓰기)
|
||||
- PowerShell 7+ 권장. 모듈: Microsoft.Graph, PoshRSJob
|
||||
|
||||
.PARAMETER SiteUrl
|
||||
SharePoint Online 사이트 URL
|
||||
|
||||
.PARAMETER ThrottleLimit
|
||||
병렬 다운로드 동시 처리 개수 (기본값 1). 초대용량 파일 백업으로 1개로 제한하여 안정성 확보 할 것!
|
||||
|
||||
.EXAMPLE
|
||||
# 기존에 실패한 파일들만 안정적으로 다시 받기
|
||||
.\Backup-SPO-Final-Enhanced.ps1 -SiteUrl "https://yoursite.sharepoint.com/sites/yoursite" -ThrottleLimit 1
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SiteUrl,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$ThrottleLimit = 1 # 기본값을 1로 변경하여 안정성 위주로 설정
|
||||
)
|
||||
|
||||
# ==================================================
|
||||
# region: 도우미 함수
|
||||
# ==================================================
|
||||
function Select-FolderDialog {
|
||||
param(
|
||||
[string]$Title = "백업 위치 선택",
|
||||
[string]$InitialDirectory = [Environment]::GetFolderPath('Desktop')
|
||||
)
|
||||
try {
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||
$dlg.Description = $Title
|
||||
$dlg.SelectedPath = $InitialDirectory
|
||||
if ($dlg.ShowDialog((New-Object System.Windows.Forms.NativeWindow))) {
|
||||
return $dlg.SelectedPath
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return Read-Host "백업 경로 입력"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Format-FileSize {
|
||||
param([long]$bytes)
|
||||
$suf = "B", "KB", "MB", "GB", "TB", "PB"
|
||||
if ($bytes -le 0) { return "0 B" }
|
||||
$i = [math]::Floor([math]::Log($bytes, 1024))
|
||||
if ($i -ge $suf.Length) { $i = $suf.Length - 1 }
|
||||
"{0:N2} {1}" -f ($bytes / [math]::Pow(1024, $i)), $suf[$i]
|
||||
}
|
||||
|
||||
function Sanitize-FileName {
|
||||
param([string]$FileName)
|
||||
$invalidChars = '[\\/:"*?<>|]'
|
||||
return ($FileName -replace $invalidChars, '_')
|
||||
}
|
||||
|
||||
function Get-AllFilesRecursive {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$DriveId,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$RootItemId,
|
||||
[Parameter(Mandatory)]
|
||||
[string]$BackupBasePath
|
||||
)
|
||||
|
||||
$filesToDownload = [System.Collections.Generic.List[object]]::new()
|
||||
$folderCount = 0
|
||||
$totalScanSize = 0
|
||||
$folderQueue = [System.Collections.Queue]::new()
|
||||
$folderQueue.Enqueue(@{ ItemId = $RootItemId; RelativePath = "" })
|
||||
|
||||
$consoleWidth = if ($Host.UI.RawUI) { $Host.UI.RawUI.WindowSize.Width } else { 80 }
|
||||
|
||||
while ($folderQueue.Count -gt 0) {
|
||||
$currentFolder = $folderQueue.Dequeue()
|
||||
if ([string]::IsNullOrWhiteSpace($currentFolder.ItemId)) { continue }
|
||||
|
||||
$originalRelativePath = $currentFolder.RelativePath
|
||||
if ($null -eq $originalRelativePath) { $originalRelativePath = "" }
|
||||
|
||||
$sanitizedRelativePath = ($originalRelativePath -split '[\\/]' | ForEach-Object { Sanitize-FileName -FileName $_ }) -join '\'
|
||||
|
||||
$statusText = "발견: 폴더 $folderCount 개, 파일 $($filesToDownload.Count) 개"
|
||||
$pathText = "탐색 중: $originalRelativePath"
|
||||
$maxLength = $consoleWidth - $statusText.Length - 15
|
||||
if ($pathText.Length -gt $maxLength) { $pathText = "..." + $pathText.Substring($pathText.Length - $maxLength) }
|
||||
|
||||
Write-Progress -Activity "파일 목록 스캔 중" -Status $statusText -CurrentOperation $pathText -Id 0
|
||||
|
||||
$items = @()
|
||||
try {
|
||||
$page = Get-MgDriveItemChild -DriveId $DriveId -DriveItemId $currentFolder.ItemId -PageSize 999 -ErrorAction Stop
|
||||
if ($null -ne $page) { $items += $page }
|
||||
|
||||
$next = $page.AdditionalProperties.'@odata.nextLink'
|
||||
while ($null -ne $next) {
|
||||
Write-Progress -Activity "파일 목록 스캔 중" -Status $statusText -CurrentOperation "탐색 중 (페이징): $originalRelativePath" -Id 0
|
||||
$page = Get-MgDriveItemChild -Uri $next -ErrorAction Stop
|
||||
if ($null -ne $page) {
|
||||
$items += $page
|
||||
$next = $page.AdditionalProperties.'@odata.nextLink'
|
||||
}
|
||||
else {
|
||||
$next = $null
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "폴더 '$originalRelativePath' 스캔 실패: $($_.Exception.Message)"
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($item in $items) {
|
||||
if (-not $item.Name) { continue }
|
||||
|
||||
$nextOriginalPath = if ([string]::IsNullOrEmpty($originalRelativePath)) { $item.Name } else { Join-Path $originalRelativePath $item.Name }
|
||||
|
||||
if ($null -ne $item.Folder.ChildCount) {
|
||||
$folderCount++
|
||||
$folderQueue.Enqueue(@{ ItemId = $item.Id; RelativePath = $nextOriginalPath })
|
||||
}
|
||||
elseif ($null -ne $item.File.MimeType -and $item.Size -gt 0) {
|
||||
$sanitizedFileName = Sanitize-FileName -FileName $item.Name
|
||||
$itemRelativePath = if ([string]::IsNullOrEmpty($sanitizedRelativePath)) { $sanitizedFileName } else { Join-Path $sanitizedRelativePath $sanitizedFileName }
|
||||
|
||||
# [수정] 스캔 시점에는 DownloadUrl을 가져오지 않고, 다운로드에 필수적인 정보만 저장
|
||||
$filesToDownload.Add([PSCustomObject]@{
|
||||
DriveId = $DriveId
|
||||
Id = $item.Id
|
||||
RelativePath = $itemRelativePath
|
||||
LocalPath = Join-Path $BackupBasePath $itemRelativePath
|
||||
Size = [long]$item.Size
|
||||
})
|
||||
$totalScanSize += [long]$item.Size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return @{
|
||||
Files = $filesToDownload
|
||||
TotalSize = [long]$totalScanSize
|
||||
FolderCount = $folderCount
|
||||
}
|
||||
}
|
||||
# endregion
|
||||
|
||||
# ==================================================
|
||||
# 메인 스크립트
|
||||
# ==================================================
|
||||
|
||||
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Write-Host "--- SharePoint 백업 (최종 강화판) 시작 ---" -ForegroundColor Yellow
|
||||
|
||||
# 1) 필수 모듈 확인 및 Microsoft Graph 연결
|
||||
Write-Host "`n[1/5] 연결 및 설정 확인..." -ForegroundColor Cyan
|
||||
if (-not (Get-Module -ListAvailable -Name PoshRSJob)) { Write-Host "[ERROR] 'PoshRSJob' 모듈이 필요합니다. (Install-Module PoshRSJob)" -ForegroundColor Red; exit }
|
||||
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph)) { Write-Host "[ERROR] 'Microsoft.Graph' 모듈이 필요합니다." -ForegroundColor Red; exit }
|
||||
|
||||
# [중요] 파일 내용 다운로드를 위한 권한 포함
|
||||
$requiredScopes = "Sites.Read.All", "Files.Read.All"
|
||||
if (-not (Get-MgContext)) {
|
||||
Connect-MgGraph -Scopes $requiredScopes
|
||||
}
|
||||
Write-Host "✅ 연결 성공: $(Get-MgContext).Account" -ForegroundColor Green
|
||||
|
||||
# 2) 백업 경로 선택 및 대상 드라이브 확인
|
||||
Write-Host "`n[2/5] 백업 대상 확인..." -ForegroundColor Cyan
|
||||
$backupRootPath = Select-FolderDialog
|
||||
if ([string]::IsNullOrWhiteSpace($backupRootPath)) {
|
||||
Write-Host "❌ 백업 경로 미선택. 종료" -ForegroundColor Red
|
||||
exit
|
||||
}
|
||||
$uri = [Uri]$SiteUrl
|
||||
$site = Get-MgSite -SiteId "$($uri.Host):$($uri.AbsolutePath)"
|
||||
$drive = Get-MgSiteDrive -SiteId $site.Id -Property "Id,Name,Quota" |
|
||||
Where-Object Name -in @("Documents", "문서") |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $drive) {
|
||||
Write-Host "❌ 문서 라이브러리 없음" -ForegroundColor Red
|
||||
exit
|
||||
}
|
||||
Write-Host "✅ 라이브러리: $($drive.Name)" -ForegroundColor Green
|
||||
$driveQuota = $drive.Quota
|
||||
Write-Host "✅ 사이트 용량: $(Format-FileSize $driveQuota.Used) / $(Format-FileSize $driveQuota.Total) 사용 중" -ForegroundColor Green
|
||||
|
||||
# 3) 전체 파일 스캔 또는 실패 목록 로드
|
||||
Write-Host "`n[3/5] 파일 목록 준비..." -ForegroundColor Cyan
|
||||
$failedFilesLogPath = Join-Path $backupRootPath "_failed_files.json"
|
||||
$isRetryMode = $false
|
||||
if (Test-Path $failedFilesLogPath) {
|
||||
Write-Host "⚠️ 이전 실행에서 실패한 파일 목록(_failed_files.json)을 발견했습니다." -ForegroundColor Yellow
|
||||
$allFiles = Get-Content $failedFilesLogPath | ConvertFrom-Json
|
||||
Write-Host "실패한 $($allFiles.Count)개의 파일에 대해 다운로드를 재시도합니다." -ForegroundColor Yellow
|
||||
$isRetryMode = $true
|
||||
$totalSize = ($allFiles | Measure-Object -Property Size -Sum).Sum
|
||||
$folderCount = "N/A (재시도 모드)"
|
||||
}
|
||||
else {
|
||||
$scan = Get-AllFilesRecursive -DriveId $drive.Id -RootItemId 'root' -BackupBasePath $backupRootPath
|
||||
Write-Progress -Id 0 -Completed
|
||||
$allFiles = $scan.Files
|
||||
$totalSize = [long]$scan.TotalSize
|
||||
$folderCount = $scan.FolderCount
|
||||
}
|
||||
Write-Host "✅ 준비 완료: $($allFiles.Count)개 파일, 총 $(Format-FileSize $totalSize)" -ForegroundColor Green
|
||||
if ($allFiles.Count -eq 0) {
|
||||
Write-Host "백업할 파일 없음. 종료."
|
||||
exit
|
||||
}
|
||||
|
||||
# 4) 병렬 다운로드 (토큰 자동 갱신 로직 포함)
|
||||
Write-Host "`n[4/5] 파일 다운로드 시작... (병렬 $ThrottleLimit)" -ForegroundColor Cyan
|
||||
if ($ThrottleLimit -gt 3) {
|
||||
Write-Warning "병렬 처리 개수가 너무 높으면 API 사용량 제한(429 오류)이 발생할 수 있습니다. 1~3 사이를 권장합니다."
|
||||
}
|
||||
|
||||
$scriptBlock = {
|
||||
param($File)
|
||||
|
||||
$status = "Unknown"
|
||||
$message = ""
|
||||
|
||||
try {
|
||||
# 1. 로컬 파일 크기 비교를 통한 건너뛰기 로직
|
||||
if (Test-Path -LiteralPath $File.LocalPath -PathType Leaf) {
|
||||
if ((Get-Item -LiteralPath $File.LocalPath).Length -eq $File.Size) {
|
||||
throw "SKIP" # 사용자 지정 예외로 건너뛰기 처리
|
||||
}
|
||||
}
|
||||
|
||||
# 2. 다운로드 폴더 생성
|
||||
$localDir = Split-Path -Path $File.LocalPath -Parent
|
||||
if (-not (Test-Path -LiteralPath $localDir)) {
|
||||
New-Item -Path $localDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
|
||||
# 3. [핵심 로직] 다운로드 직전, 최신 DownloadUrl을 다시 요청하여 토큰을 갱신
|
||||
# Get-MgDriveItem cmdlet은 토큰이 만료되면 자동으로 갱신을 시도함.
|
||||
$message = "인증 정보 갱신 중..."
|
||||
$latestItem = Get-MgDriveItem -DriveId $File.DriveId -DriveItemId $File.Id
|
||||
$downloadUrl = $latestItem.AdditionalProperties.'@microsoft.graph.downloadUrl'
|
||||
|
||||
if (-not $downloadUrl) { throw "최신 DownloadUrl을 가져오는 데 실패했습니다." }
|
||||
|
||||
# 4. 실제 다운로드 실행
|
||||
$message = "다운로드 시작..."
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $File.LocalPath -TimeoutSec 7200 -UseBasicParsing -ErrorAction Stop # Timeout 2시간으로 증가
|
||||
|
||||
$status = "Success"
|
||||
$message = "다운로드 성공"
|
||||
}
|
||||
catch {
|
||||
if ($_.Exception.Message -eq "SKIP") {
|
||||
$status = "Skipped"
|
||||
$message = "파일 크기 동일, 건너뜀"
|
||||
}
|
||||
else {
|
||||
$status = "Failure"
|
||||
$message = $_.Exception.Message.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
Timestamp = Get-Date
|
||||
Status = $status
|
||||
File = $File.RelativePath
|
||||
Size = [long]$File.Size
|
||||
Message = $message
|
||||
}
|
||||
}
|
||||
|
||||
Get-RSJob | Remove-RSJob # 이전 작업 정리
|
||||
$jobs = $allFiles | Start-RSJob -ScriptBlock $scriptBlock -Throttle $ThrottleLimit
|
||||
$totalFiles = $allFiles.Count
|
||||
$downloadStartTime = Get-Date
|
||||
$completedCount = 0
|
||||
|
||||
while ($completedCount -lt $totalFiles) {
|
||||
$completedCount = ($jobs | Where-Object State -in 'Completed', 'Failed').Count
|
||||
|
||||
if ($totalFiles -gt 0) {
|
||||
$percent = [math]::Round(($completedCount / $totalFiles) * 100)
|
||||
$elapsedSec = ((Get-Date) - $downloadStartTime).TotalSeconds
|
||||
$statusText = "$percent% 완료 ($completedCount/$totalFiles) | 경과 시간: $([TimeSpan]::FromSeconds($elapsedSec).ToString('hh\:mm\:ss'))"
|
||||
$runningJobNames = ($jobs | Where-Object State -eq 'Running' | Select-Object -First 1).Name
|
||||
$currentOperation = if ($runningJobNames) { "다운로드 중: $runningJobNames" } else { "마무리 중..." }
|
||||
|
||||
Write-Progress -Id 1 -Activity "SharePoint 백업 진행 중" -Status $statusText -CurrentOperation $currentOperation -PercentComplete $percent
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
|
||||
$results = $jobs | Wait-RSJob | Receive-RSJob
|
||||
Write-Progress -Id 1 -Completed
|
||||
$stopwatch.Stop()
|
||||
Write-Host "✅ 파일 다운로드 완료!" -ForegroundColor Green
|
||||
|
||||
# ==================================================
|
||||
# 5) 결과 보고서 생성/출력/저장
|
||||
# ==================================================
|
||||
Write-Host "`n[5/5] 결과 보고서 생성..." -ForegroundColor Cyan
|
||||
|
||||
$successArray = $results | Where-Object Status -in @('Success', 'Skipped')
|
||||
$failureArray = $results | Where-Object Status -eq 'Failure'
|
||||
|
||||
$successCount = ($successArray | Where-Object Status -eq 'Success').Count
|
||||
$skippedCount = ($successArray | Where-Object Status -eq 'Skipped').Count
|
||||
$failureCount = $failureArray.Count
|
||||
|
||||
if ($isRetryMode) {
|
||||
$previousSuccessLogPath = Join-Path $backupRootPath "_success_log.json"
|
||||
if (Test-Path $previousSuccessLogPath) {
|
||||
$previousSuccess = Get-Content $previousSuccessLogPath | ConvertFrom-Json
|
||||
$successCount += ($previousSuccess | Where-Object Status -eq 'Success').Count
|
||||
$skippedCount += ($previousSuccess | Where-Object Status -eq 'Skipped').Count
|
||||
}
|
||||
}
|
||||
|
||||
$otherStorageBytes = [math]::Max([long]0, ([long]$driveQuota.Used - [long]$totalSize))
|
||||
$downloadedTotal = ($results | Where-Object Status -eq 'Success' | Measure-Object -Property Size -Sum).Sum
|
||||
if ($null -eq $downloadedTotal) { $downloadedTotal = 0 }
|
||||
$elapsedForDownload = $stopwatch.Elapsed.TotalSeconds
|
||||
$avgBpsFinal = if ($elapsedForDownload -gt 0) { [double]$downloadedTotal / $elapsedForDownload } else { 0.0 }
|
||||
|
||||
$report = New-Object System.Text.StringBuilder
|
||||
$null = $report.AppendLine("==================================================")
|
||||
$null = $report.AppendLine(" SharePoint 백업 결과 보고서 (PoshRSJob)")
|
||||
$null = $report.AppendLine("==================================================")
|
||||
$null = $report.AppendLine(" > 백업 일시 : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
|
||||
$null = $report.AppendLine(" > 대상 사이트 : $SiteUrl")
|
||||
$null = $report.AppendLine(" > 저장 위치 : $backupRootPath")
|
||||
$null = $report.AppendLine("--------------------------------------------------")
|
||||
$null = $report.AppendLine(" [ 사이트 저장소 현황 ]")
|
||||
$null = $report.AppendLine((" - 전체 할당량 (Quota)".PadRight(25) + ": $(Format-FileSize $driveQuota.Total)"))
|
||||
$null = $report.AppendLine((" - 실제 총 사용량".PadRight(25) + ": $(Format-FileSize $driveQuota.Used)"))
|
||||
$null = $report.AppendLine((" - 현재 파일 총 용량".PadRight(25) + ": $(Format-FileSize $totalSize)"))
|
||||
$null = $report.AppendLine((" - 기타 용량 (버전 기록 등)".PadRight(25) + ": $(Format-FileSize $otherStorageBytes)"))
|
||||
$null = $report.AppendLine("--------------------------------------------------")
|
||||
$null = $report.AppendLine(" [ 백업된 파일 정보 ]")
|
||||
$null = $report.AppendLine(" - 스캔된 총 폴더 수 : $folderCount 개")
|
||||
$null = $report.AppendLine(" - 스캔된 총 파일 수 : $($allFiles.Count) 개")
|
||||
$null = $report.AppendLine("--------------------------------------------------")
|
||||
$null = $report.AppendLine(" [ 백업 작업 결과 ]")
|
||||
$null = $report.AppendLine(" - ✅ 성공 : $successCount 개")
|
||||
$null = $report.AppendLine(" - ⏩ 건너뜀 : $skippedCount 개")
|
||||
$null = $report.AppendLine(" - ❌ 실패 : $failureCount 개")
|
||||
$null = $report.AppendLine(" - ⏱️ 총 소요 시간 : $($stopwatch.Elapsed.ToString('hh\:mm\:ss'))")
|
||||
$null = $report.AppendLine(" - ↓ 실제 평균 속도 : $(Format-FileSize([long][math]::Round($avgBpsFinal)))/s")
|
||||
$null = $report.AppendLine("==================================================")
|
||||
|
||||
if ($failureCount -gt 0) {
|
||||
$failedFilesToSave = $failureArray | ForEach-Object {
|
||||
$originalFile = $allFiles | Where-Object RelativePath -eq $_.File | Select-Object -First 1
|
||||
[PSCustomObject]@{
|
||||
DriveId = $originalFile.DriveId
|
||||
Id = $originalFile.Id
|
||||
RelativePath = $originalFile.RelativePath
|
||||
LocalPath = $originalFile.LocalPath
|
||||
Size = $originalFile.Size
|
||||
}
|
||||
}
|
||||
$failedFilesToSave | ConvertTo-Json | Set-Content -Path $failedFilesLogPath -Encoding UTF8
|
||||
$successArray | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $backupRootPath "_success_log.json") -Encoding UTF8
|
||||
|
||||
$null = $report.AppendLine(" [ 실패 항목 상세 ]")
|
||||
foreach ($f in ($failureArray | Sort-Object File)) {
|
||||
$null = $report.AppendLine(" ❌ $($f.File)")
|
||||
$null = $report.AppendLine(" └ 원인: $($f.Message)")
|
||||
}
|
||||
$null = $report.AppendLine("==================================================")
|
||||
|
||||
Write-Warning "`n$failureCount 개의 파일 다운로드에 실패했습니다. 실패 목록을 '$failedFilesLogPath'에 저장했습니다. 스크립트를 다시 실행하면 실패한 파일만 재시도합니다."
|
||||
}
|
||||
else {
|
||||
Remove-Item -Path $failedFilesLogPath -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path (Join-Path $backupRootPath "_success_log.json") -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$finalReport = $report.ToString()
|
||||
$finalReport.Split([Environment]::NewLine) | ForEach-Object {
|
||||
if ($_ -like "*✅*") { Write-Host $_ -ForegroundColor Green }
|
||||
elseif ($_ -like "*❌*") { Write-Host $_ -ForegroundColor Red }
|
||||
elseif ($_ -like "*⏩*" -or $_ -like "*⏱️*") { Write-Host $_ -ForegroundColor Yellow }
|
||||
elseif ($_ -like "*[*]*" -or $_ -like "*>*") { Write-Host $_ -ForegroundColor Cyan }
|
||||
else { Write-Host $_ }
|
||||
}
|
||||
|
||||
$logPath = Join-Path $backupRootPath "backup_report_$(Get-Date -Format yyyyMMdd_HHmmss).log"
|
||||
$finalReport | Out-File -FilePath $logPath -Encoding UTF8
|
||||
Write-Host "`n보고서 저장 위치:" -ForegroundColor Green
|
||||
Write-Host $logPath
|
||||
Reference in New Issue
Block a user