Compare commits
2
Commits
555e928e01
...
dd3e2fe885
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3e2fe885 | ||
|
|
51b97c4556 |
@@ -9,6 +9,7 @@
|
|||||||
2. sessions whose JobId matches no job (the runs that are going missing)
|
2. sessions whose JobId matches no job (the runs that are going missing)
|
||||||
3. for jobs with no matching sessions, their type/link-related properties and methods
|
3. for jobs with no matching sessions, their type/link-related properties and methods
|
||||||
4. what other job cmdlets (e.g. Get-VBRBackupCopyJob) return
|
4. what other job cmdlets (e.g. Get-VBRBackupCopyJob) return
|
||||||
|
5. each job's schedule properties, which the collector reads for the next run
|
||||||
Nothing is changed in Veeam or the dashboard. Output is also saved to a text file.
|
Nothing is changed in Veeam or the dashboard. Output is also saved to a text file.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
@@ -42,14 +43,14 @@ function Format-Value($Value) {
|
|||||||
|
|
||||||
# Property and method names matching $LinkPattern, with property values. Every read is guarded:
|
# Property and method names matching $LinkPattern, with property values. Every read is guarded:
|
||||||
# some Veeam properties throw when accessed.
|
# some Veeam properties throw when accessed.
|
||||||
function Show-LinkMembers($Object) {
|
function Show-LinkMembers($Object, [string]$Pattern = $LinkPattern, [string]$Indent = ' ') {
|
||||||
foreach ($p in $Object.PSObject.Properties | Where-Object { $_.Name -match $LinkPattern }) {
|
foreach ($p in $Object.PSObject.Properties | Where-Object { $_.Name -match $Pattern }) {
|
||||||
$value = try { Format-Value $p.Value } catch { "<error: $($_.Exception.Message)>" }
|
$value = try { Format-Value $p.Value } catch { "<error: $($_.Exception.Message)>" }
|
||||||
' {0} = {1}' -f $p.Name, $value
|
'{0}{1} = {2}' -f $Indent, $p.Name, $value
|
||||||
}
|
}
|
||||||
$methods = @($Object | Get-Member -MemberType Methods -ErrorAction SilentlyContinue |
|
$methods = @($Object | Get-Member -MemberType Methods -ErrorAction SilentlyContinue |
|
||||||
Where-Object { $_.Name -match $LinkPattern } | ForEach-Object Name)
|
Where-Object { $_.Name -match $Pattern } | ForEach-Object Name)
|
||||||
if ($methods) { ' methods: ' + ($methods -join ', ') }
|
if ($methods) { $Indent + 'methods: ' + ($methods -join ', ') }
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-Prop($Object, [string]$Name) {
|
function Get-Prop($Object, [string]$Name) {
|
||||||
@@ -147,6 +148,39 @@ $report = & {
|
|||||||
" ${cmd}: error: $($_.Exception.Message)"
|
" ${cmd}: error: $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
''
|
||||||
|
|
||||||
|
# The collector uses IsScheduleEnabled and ScheduleOptions.NextRun; show what else is there in
|
||||||
|
# case this version keeps the next run somewhere different
|
||||||
|
"== 5. Schedules (culture: $([Globalization.CultureInfo]::CurrentCulture.Name))"
|
||||||
|
$SchedulePattern = 'Schedul|Next|Latest|Run|Enabled|Daily|Periodic|Monthly|Continuous|After|Option'
|
||||||
|
foreach ($j in $jobs) {
|
||||||
|
" $($j.Name) [$(Get-Prop $j 'JobType')]"
|
||||||
|
Show-LinkMembers $j $SchedulePattern ' '
|
||||||
|
foreach ($source in 'ScheduleOptions', 'GetScheduleOptions()', 'Get-VBRJobScheduleOptions') {
|
||||||
|
try {
|
||||||
|
$opts = switch ($source) {
|
||||||
|
'ScheduleOptions' { $j.ScheduleOptions }
|
||||||
|
'GetScheduleOptions()' { $j.GetScheduleOptions() }
|
||||||
|
'Get-VBRJobScheduleOptions' { Get-VBRJobScheduleOptions -Job $j -WarningAction SilentlyContinue }
|
||||||
|
}
|
||||||
|
if ($null -eq $opts) { " ${source}: <null>"; continue }
|
||||||
|
" ${source} ($($opts.GetType().FullName)):"
|
||||||
|
foreach ($p in $opts.PSObject.Properties) {
|
||||||
|
$value = try { $p.Value } catch { "<error: $($_.Exception.Message)>" }
|
||||||
|
$type = if ($null -eq $value) { 'null' } else { $value.GetType().Name }
|
||||||
|
if ($value -is [string] -or $value -is [ValueType] -or $null -eq $value) {
|
||||||
|
' {0} ({1}) = {2}' -f $p.Name, $type, (Format-Value $value)
|
||||||
|
} else {
|
||||||
|
' {0} ({1})' -f $p.Name, $type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
" ${source}: not available: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
''
|
||||||
|
}
|
||||||
} | Out-String -Width 300
|
} | Out-String -Width 300
|
||||||
|
|
||||||
$report
|
$report
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ delete the scheduled task and both `BackupDashboard` folders.
|
|||||||
| Job | Job name. The job's GUID is the ID, so renaming a job in Veeam doesn't duplicate it |
|
| Job | Job name. The job's GUID is the ID, so renaming a job in Veeam doesn't duplicate it |
|
||||||
| Status | Session result (Success / Warning / Failed). Any state other than Stopped is **Running**. A session that stopped with no result (e.g. cancelled) is reported as **Warning** |
|
| Status | Session result (Success / Warning / Failed). Any state other than Stopped is **Running**. A session that stopped with no result (e.g. cancelled) is reported as **Warning** |
|
||||||
| Started / finished | Session `CreationTime` / `EndTime` |
|
| Started / finished | Session `CreationTime` / `EndTime` |
|
||||||
| Next scheduled | Job's next run. Sent as empty for disabled jobs and chained ("run after") jobs, so those are never flagged Overdue |
|
| Next scheduled | Job's next run, from the live schedule (`GetScheduleOptions()`). Sent as empty, so never flagged Overdue, for disabled jobs, chained ("run after") jobs, and immediate-mode backup copy jobs, which run whenever their source does |
|
||||||
| Message | Session reason, each VM or object that didn't succeed, and warning/error lines from the session log |
|
| Message | Session reason, each VM or object that didn't succeed, and warning/error lines from the session log |
|
||||||
| Size | `BackupStats.BackupSize` |
|
| Size | `BackupStats.BackupSize` |
|
||||||
|
|
||||||
|
|||||||
@@ -85,14 +85,25 @@ function Import-Veeam {
|
|||||||
function Get-NextRun($Job) {
|
function Get-NextRun($Job) {
|
||||||
try {
|
try {
|
||||||
if (-not $Job.IsScheduleEnabled) { return $null }
|
if (-not $Job.IsScheduleEnabled) { return $null }
|
||||||
$raw = $Job.ScheduleOptions.NextRun
|
# Immediate-mode backup copy jobs run whenever their source job does. Their schedule still
|
||||||
|
# carries a NextRun, but it's a stale date (months old on VBR 13) that would show as Overdue.
|
||||||
|
if ($Job.IsContinuous) { return $null }
|
||||||
|
|
||||||
|
# The ScheduleOptions property is a stale snapshot on VBR 13 (NextRun empty, last run years
|
||||||
|
# old); GetScheduleOptions() reads the live schedule. Fall back for versions without it.
|
||||||
|
$options = $null
|
||||||
|
try { $options = $Job.GetScheduleOptions() } catch { }
|
||||||
|
if (-not $options) { $options = $Job.ScheduleOptions }
|
||||||
|
$raw = $options.NextRun
|
||||||
if (-not $raw) { return $null } # e.g. chained jobs that run "after" another job
|
if (-not $raw) { return $null } # e.g. chained jobs that run "after" another job
|
||||||
if ($raw -is [datetime]) { return ConvertTo-IsoTime $raw }
|
if ($raw -is [datetime]) { return ConvertTo-IsoTime $raw }
|
||||||
# Older versions return a string formatted with the server's culture, so parse with it
|
# A string formatted by VBR ("2026-10-09 12:15:00 AM" on VBR 13, en-CA). Try the server's
|
||||||
$parsed = [datetime]::MinValue
|
# culture first, then the invariant one
|
||||||
if ([datetime]::TryParse([string]$raw, [Globalization.CultureInfo]::CurrentCulture,
|
foreach ($culture in [Globalization.CultureInfo]::CurrentCulture, [Globalization.CultureInfo]::InvariantCulture) {
|
||||||
[Globalization.DateTimeStyles]::None, [ref]$parsed)) {
|
$parsed = [datetime]::MinValue
|
||||||
return ConvertTo-IsoTime $parsed
|
if ([datetime]::TryParse([string]$raw, $culture, [Globalization.DateTimeStyles]::None, [ref]$parsed)) {
|
||||||
|
return ConvertTo-IsoTime $parsed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Write-Log "Couldn't parse next run '$raw' for job '$($Job.Name)'" 'WARN'
|
Write-Log "Couldn't parse next run '$raw' for job '$($Job.Name)'" 'WARN'
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -12,12 +12,22 @@ function New-StableGuid([string]$Seed) {
|
|||||||
}
|
}
|
||||||
$notSet = Get-Date '1900-01-01'
|
$notSet = Get-Date '1900-01-01'
|
||||||
|
|
||||||
function New-MockJob($Name, $Type, $NextRun, [bool]$Enabled = $true) {
|
# As on VBR 13: the ScheduleOptions property is a stale snapshot with an empty NextRun, and the live
|
||||||
[pscustomobject]@{
|
# schedule (NextRun as a "2026-10-09 12:15:00 AM" string) comes from GetScheduleOptions().
|
||||||
|
# -Continuous: an immediate-mode copy job, whose live NextRun is a stale date in the past.
|
||||||
|
function New-MockJob($Name, $Type, $NextRun, [bool]$Enabled = $true, [switch]$Continuous) {
|
||||||
|
$job = [pscustomobject]@{
|
||||||
Id = New-StableGuid $Name; Name = $Name; JobType = $Type
|
Id = New-StableGuid $Name; Name = $Name; JobType = $Type
|
||||||
IsScheduleEnabled = $Enabled
|
IsScheduleEnabled = $Enabled
|
||||||
ScheduleOptions = [pscustomobject]@{ NextRun = $NextRun }
|
IsContinuous = [bool]$Continuous
|
||||||
|
ScheduleOptions = [pscustomobject]@{ NextRun = '' }
|
||||||
|
LiveNextRun = $NextRun
|
||||||
}
|
}
|
||||||
|
$job | Add-Member ScriptMethod GetScheduleOptions { [pscustomobject]@{ NextRun = $this.LiveNextRun } }
|
||||||
|
$job
|
||||||
|
}
|
||||||
|
function Format-VbrTime([datetime]$Value) {
|
||||||
|
$Value.ToString('yyyy-MM-dd h:mm:ss tt', [Globalization.CultureInfo]::InvariantCulture)
|
||||||
}
|
}
|
||||||
|
|
||||||
function New-MockSession($Job, $Start, $End, $State, $Result, $Reason = '', $Tasks = @(), $LogRecords = @(), $Size = 0) {
|
function New-MockSession($Job, $Start, $End, $State, $Result, $Reason = '', $Tasks = @(), $LogRecords = @(), $Size = 0) {
|
||||||
@@ -40,13 +50,13 @@ function New-MockTask($Name, $Status, $Reason = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$global:MockJobs = @(
|
$global:MockJobs = @(
|
||||||
(New-MockJob 'File Server – Nightly' 'Backup' $now.Date.AddDays(1).AddHours(22)),
|
(New-MockJob 'File Server – Nightly' 'Backup' (Format-VbrTime $now.Date.AddDays(1).AddMinutes(15))),
|
||||||
# Older VBR versions return NextRun as a culture-formatted string
|
# Some versions return NextRun as a DateTime
|
||||||
(New-MockJob 'SQL01 – Logs' 'Backup' $now.AddMinutes(40).ToString()),
|
(New-MockJob 'SQL01 – Logs' 'Backup' $now.AddMinutes(40)),
|
||||||
(New-MockJob 'Offsite Copy' 'BackupSync' $null),
|
(New-MockJob 'Offsite Copy' 'BackupSync' $null),
|
||||||
(New-MockJob 'Old Exchange' 'Backup' $now.AddDays(1) $false),
|
(New-MockJob 'Old Exchange' 'Backup' $now.AddDays(1) $false),
|
||||||
# VBR 12+ backup copy job (immediate mode, no schedule): the "policy" job Get-VBRJob returns
|
# VBR 12+ backup copy job (immediate mode, no schedule): the "policy" job Get-VBRJob returns
|
||||||
(New-MockJob 'Cloud Copy' 'SimpleBackupCopyPolicy' $null)
|
(New-MockJob 'Cloud Copy' 'SimpleBackupCopyPolicy' (Format-VbrTime $now.AddMonths(-6)) -Continuous)
|
||||||
)
|
)
|
||||||
$fs, $sql, $copy, $old, $cloud = $global:MockJobs
|
$fs, $sql, $copy, $old, $cloud = $global:MockJobs
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -95,8 +95,8 @@ function statusBadge(status, { asButton = false, opensDialog = false, dataset =
|
|||||||
|
|
||||||
const runningTitle = (since) => (since ? `Running since ${formatDateTime(since)}` : null);
|
const runningTitle = (since) => (since ? `Running since ${formatDateTime(since)}` : null);
|
||||||
|
|
||||||
function timeCell(date, extra = null) {
|
function timeCell(date, { extra = null, emptyLabel = 'Never' } = {}) {
|
||||||
if (!date) return el('td', {}, el('span', { class: 'muted' }, 'Never'));
|
if (!date) return el('td', {}, el('span', { class: 'muted' }, emptyLabel));
|
||||||
return el('td', {},
|
return el('td', {},
|
||||||
el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() },
|
el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() },
|
||||||
el('span', {}, formatDateTime(date), extra),
|
el('span', {}, formatDateTime(date), extra),
|
||||||
@@ -112,7 +112,8 @@ function renderRow(job) {
|
|||||||
asButton: true, opensDialog: true, dataset: { jobId: job.id }, title: runningTitle(job.runningSince),
|
asButton: true, opensDialog: true, dataset: { jobId: job.id }, title: runningTitle(job.runningSince),
|
||||||
})),
|
})),
|
||||||
timeCell(job.lastRun),
|
timeCell(job.lastRun),
|
||||||
timeCell(job.nextRun, overdueTag));
|
// No next run: unscheduled, or e.g. a Veeam copy job in immediate mode
|
||||||
|
timeCell(job.nextRun, { extra: overdueTag, emptyLabel: 'Not scheduled' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
||||||
@@ -304,7 +305,7 @@ function renderSummarySection() {
|
|||||||
// While a job is running, its "last run" is the previous one — say how that went
|
// While a job is running, its "last run" is the previous one — say how that went
|
||||||
status === 'running' && lastStatus ? detailRow('Previous result', STATUS[lastStatus].label) : [],
|
status === 'running' && lastStatus ? detailRow('Previous result', STATUS[lastStatus].label) : [],
|
||||||
detailRow('Last finished', formatDateTime(detail ? parseDate(detail.last_run_at) : job.lastRun)),
|
detailRow('Last finished', formatDateTime(detail ? parseDate(detail.last_run_at) : job.lastRun)),
|
||||||
detailRow('Next scheduled', formatDateTime(detail ? parseDate(detail.next_run_at) : job.nextRun)),
|
detailRow('Next scheduled', formatDateTime(detail ? parseDate(detail.next_run_at) : job.nextRun, 'Not scheduled')),
|
||||||
detailRow('Duration', formatDuration(detail?.duration_seconds)),
|
detailRow('Duration', formatDuration(detail?.duration_seconds)),
|
||||||
detailRow('Size', formatBytes(detail?.size_bytes)),
|
detailRow('Size', formatBytes(detail?.size_bytes)),
|
||||||
detailRow('Source', detail?.source_system)));
|
detailRow('Source', detail?.source_system)));
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ export function parseDate(value) {
|
|||||||
return Number.isNaN(d.getTime()) ? null : d;
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatDateTime = (d) => (d ? dateTimeFmt.format(d) : '—');
|
export const formatDateTime = (d, emptyLabel = '—') => (d ? dateTimeFmt.format(d) : emptyLabel);
|
||||||
export const formatTime = (d) => timeFmt.format(d);
|
export const formatTime = (d) => timeFmt.format(d);
|
||||||
|
|
||||||
export function formatRelative(d, now = Date.now()) {
|
export function formatRelative(d, now = Date.now()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user