75 Commits
Author SHA1 Message Date
kyleandClaude Opus 5.5 2fa9eea274 Replace basic auth with Microsoft 365 sign-in
nginx now checks every dashboard and API request with oauth2-proxy
(auth_request), which signs users in with Microsoft Entra ID. Who may
sign in is controlled in Entra: a single-tenant app registration with
"Assignment required", so only assigned users/groups get through.

- deploy/oauth2-proxy.cfg (entra-id provider, auth-only mode, secure
  cookie, trusted proxy = localhost) and deploy/oauth2-proxy.service
- nginx: pages redirect to Microsoft sign-in and back to the same URL;
  API calls get a plain 401 (the page already asks to reload);
  /api/v1/ingest stays key-only for collectors; /oauth2/auth is
  internal; the signed-in user is passed to the API in headers nginx
  sets (so browsers can't spoof them); dashboard files are served with
  Cache-Control: no-cache so updates show up at once
- GET /api/v1/me; the top bar shows the signed-in user and a Sign out
  that also signs out of Microsoft (otherwise SSO signs straight back in)
- README: Entra and server setup steps

Tested locally with oauth2-proxy 7.15.5 and nginx 1.31 running the
deploy configs (TLS, self-signed): redirects to the tenant's sign-in
with the return URL kept, 401s, ingest bypass, sign-out allow-list,
cookie flags, security headers. The signed-in path was tested with a
stand-in for oauth2-proxy's auth answer; a real Entra sign-in wasn't.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 11:31:35 -06:00
kyleandClaude Opus 5.5 8d0e6dca71 Move code shared by the collectors into one module
The Veeam, MARS, WSB and VB365 collectors each carried identical copies
of logging, ISO time conversion, property lookup, posting to the API,
config loading, TLS setup, the sent-runs state file and the 401 check,
and their installers repeated the same key prompt, locked-down config,
file copy and scheduled-task registration. These now live in
collectors/common/BackupDashboard.Collector.psm1:

- Initialize-Collector / Test-RunSent / Send-JobReport /
  Test-AuthRejected / Complete-Collector for collector runs
- Write-Log, Get-ErrorText, ConvertTo-IsoTime, Get-FirstValue
- Install-CollectorTask for the shared half of each installer

Collectors find the module next to themselves (installed) or in
..\common (repo); installers copy it into Program Files beside the
collector. The diagnostic scripts stay standalone single files.

No behavior change: a harness ran each collector against its mock
(12 scenarios: dry run, two real runs against a stub API, state file,
logs, exit codes, rejected key, en-GB, backfill) before and after, and
all 84 outputs are identical. Installers, run with stand-ins for the
Windows-only commands, write identical configs and tasks; the only
differences are the module being copied and the summary line naming
the shell's path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 11:09:01 -06:00
kyleandClaude Opus 5.5 118a7f978d Fix VB365 collector against a real server's object model
Checked with Get-Vb365CollectorDiagnostics.ps1 output from a VB365 8.4
server (GDC-VEEAM-04, 11 organizations, Windows PowerShell 5.1). Times
(session CreationTime/EndTime, job NextRun) are local, and property
names matched. Two fixes:

- Session log items carry their status as a Title prefix ("[Warning]
  ...") with no separate status property. Match on the prefix and strip
  it, instead of matching words in the text, which would have pulled in
  lines like "[Success] Processing site: .../error-reports"
- Retries are separate sessions (MainSessionId -> the retried session,
  which has JobWillBeRetried = True). Mark them in details (retry_of,
  will_retry) and say "Veeam will retry this job." on a pending retry

Mock rebuilt on the real shapes, including a warning + retry pair.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 10:47:40 -06:00
kyleandClaude Opus 5.5 3f407ba76e Add Veeam Backup for Microsoft 365 collector
PowerShell script for the VB365 server, run hourly as a SYSTEM scheduled
task (Windows PowerShell 5.1 by default, -PwshPath for PowerShell 7).
One dashboard job per VB365 backup job and backup copy job.

- Sessions from Get-VBOJobSession (and Get-VBOCopyJobSession where
  available) within the lookback window; status from session Status,
  message from the session log's warning/error lines, size from
  Statistics.TransferredData ("1.5 GB" -> bytes)
- One VB365 server can protect several Microsoft 365 organizations, so
  the dashboard company is chosen per job: OrganizationTenants mapping,
  else Tenant, else the organization's name
- Next run from the job's NextRun; empty for disabled jobs
- Get-Vb365CollectorDiagnostics.ps1 dumps jobs and sessions, showing
  each DateTime's Kind (local vs UTC); installer adapted from MARS

Not yet run against a real server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 10:31:43 -06:00
kyleandClaude Opus 5.5 dd3e2fe885 Read Veeam next run from the live schedule
On VBR 13 a job's ScheduleOptions property is a stale snapshot: NextRun
is empty (and its LatestRunLocal years old), so every Veeam job showed
no next run and could never be flagged Overdue. GetScheduleOptions() /
Get-VBRJobScheduleOptions return the live schedule ("2026-10-09
12:15:00 AM"). Use GetScheduleOptions(), falling back to the property.

Immediate-mode backup copy jobs (IsContinuous) have a live NextRun too,
but it's a stale date months in the past, which would show as Overdue;
keep reporting no next run for them.

Parsing tries the current culture, then invariant; VBR 13's format
parsed correctly under en-CA, en-US, fr-CA and en-GB. Mock updated to
VBR 13's schedule shape.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 09:53:55 -06:00
kyleandClaude Opus 5.5 51b97c4556 Diagnose Veeam schedules; show "Not scheduled" for no next run
Veeam jobs on a VBR 13 server show no next run on the dashboard. The
diagnostics script gains a section dumping each job's schedule
properties (ScheduleOptions, GetScheduleOptions(),
Get-VBRJobScheduleOptions) and the current culture, to find where VBR 13
keeps the next run.

The dashboard showed "Never" for a missing next run, which reads as
"this job will never run"; it now says "Not scheduled" (e.g. immediate
mode copy jobs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 09:42:01 -06:00
kyleandClaude Opus 5.5 555e928e01 Cap WSB job history request at 100
Get-WBJob -Previous only accepts 1-100, so asking for 200 failed
collector setup on every run ("The 200 argument is greater than the
maximum allowed range of 100"). 100 still covers a 30-day backfill at
up to 3 backups a day. The mock now enforces the same range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 09:33:07 -06:00
kyleandClaude Opus 5.5 b625a8af65 Fix WSB collector against a real server's object model
Checked with Get-WsbCollectorDiagnostics.ps1 output from a Server 2019
Hyper-V host (AAOG-EDM-HVR1):

- WBJob StartTime/EndTime are culture-formatted local-time strings
  ("10/6/2026 9:00 PM"). Parse them with the current culture: the
  [datetime] cast uses the invariant (US) format and would misread
  dd/MM dates, e.g. on en-CA/en-GB servers. Confirmed local, not UTC,
  against the Microsoft-Windows-Backup event log
- Hyper-V backups nest VMs under a VirtualMachineList item's
  SubItemList; failures, names and the items list now come from the
  leaf items, falling back to a parent's SubItemFailed count
- Report size from the top-level items' BytesProcessed
- FailureLogPath is set even on success: running backups get no
  message, and the log is only cited for warnings/failures
- HRESULTs are strings; conversion tolerates hex and bad values
- Mock rebuilt on the real shapes; tested under en-US and en-GB

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 16:14:03 -06:00
kyleandClaude Opus 5.5 a14b1b0dc9 Add Windows Server Backup collector
PowerShell script for servers using Windows Server Backup, run hourly as
a SYSTEM scheduled task under Windows PowerShell 5.1. Each server is one
dashboard job.

- Backup jobs from Get-WBJob (in progress and -Previous) within the
  lookback window; restores are ignored. WSB jobs have no ID, so runs
  are keyed on start time, which also merges a Running report with the
  finished one
- Error HRESULT -> failed; detailed error or a failed item -> warning;
  messages include the error description, codes as 0x8XXXXXXX, failed
  items and the failure log path
- Next run from Get-WBSummary's NextBackupTime
- Get-WsbCollectorDiagnostics.ps1 dumps the cmdlets' real objects and
  recent Microsoft-Windows-Backup events (to check whether job times
  are local or UTC); installer adapted from the MARS one

Not yet run against a real server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 15:56:04 -06:00
kyleandClaude Opus 5.5 5982fc9434 Fix MARS collector against a real agent's object model
Checked with Get-MarsCollectorDiagnostics.ps1 output from MARS agent
2.0.9274 (VA-FS-01):

- JobStatus.StartTime/EndTime are UTC values not marked as UTC
  (DisplayTime is the local one); they were read as local, putting
  runs 6 hours off. Lookback filtering now compares in UTC too
- ErrorInfo is always present with ErrorCode 0 on success; every
  completed backup would have been a warning. Only non-zero error
  codes count
- Schedule times are TimeSpans; casting them to DateTime failed, so
  next_run_at was always empty
- DatasourceStatus.Datasource is an object; use its DataSourceName
- Also report FileProgress.Failed file counts and FailedFileLog
- Mock rebuilt on the real shapes; diagnostics handle no System State
  policy

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 15:40:08 -06:00
kyleandClaude Opus 5.5 10181e014b Add Azure Backup (MARS agent) collector
PowerShell script for servers running the MARS agent, run as a SYSTEM
scheduled task (hourly) under Windows PowerShell 5.1, which the
agent's MSOnlineBackup module targets. Each server is one dashboard job.

- Backup jobs from Get-OBJob (in progress and -Previous) within the
  lookback window; restores are ignored. Completed with failed bytes
  or an error reports as warning
- Next run computed from the backup and System State policy schedules;
  left empty for every-N-weeks schedules to avoid false Overdue alarms
- Property reads tolerate unknown names, since the agent's object
  model isn't documented; Get-MarsCollectorDiagnostics.ps1 dumps the
  real structure for checking
- Install-MarsCollector.ps1 mirrors the Veeam installer; tests/MockMars.ps1
  provides stand-in cmdlets

Not yet run against a real MARS agent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 15:23:46 -06:00
kyleandClaude Opus 5.5 b4b703ceee Repair files broken by the web-UI merge
The merge of add-backend-api into main committed unresolved conflicts
and corrupted uploads; the API could not import on main.

- backend/app/settings.py, deploy/backup-dashboard-api.service,
  collectors/veeam/README.md: remove conflict markers, restoring the
  add-backend-api versions
- backend/app/__init__.py, backend/tests/__init__.py: were 1024 null
  bytes ("source code string cannot contain null bytes"); now empty
- Remove the accidental Backup-Dashboard gitlink at the repo root and a
  duplicate collectors/veeam/MockVeeam.ps1 (identical to tests/)
- Restore the 60-minute collector interval from 17ee303, which the
  merge reverted to 15, and update the docs to match

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 15:13:49 -06:00
kyle 10bfc7a3aa Update collectors/veeam/README.md 2026-10-07 21:02:46 +00:00
kyle 960839ed76 Update collectors/veeam/README.md 2026-10-07 21:01:23 +00:00
kyle f0b4d84c0b Merging 2026-10-07 15:01:02 -06:00
kyle 067b714678 Upload files to "/" 2026-10-07 20:10:11 +00:00
kyle 3355a7d4bc Update collectors/veeam/Send-VeeamBackupStatus.ps1 2026-10-07 20:09:47 +00:00
kyle e916afaae1 Update collectors/veeam/MockVeeam.ps1 2026-10-07 20:09:36 +00:00
kyle 1f96d11a27 Update collectors/veeam/Install-VeeamCollector.ps1 2026-10-07 20:09:26 +00:00
kyle 41c4fa2700 Delete collectors/veeam/Send-VeeamBackupStatus.ps1 2026-10-07 20:09:13 +00:00
kyle a84a35463b Delete collectors/veeam/MockVeeam.ps1 2026-10-07 20:09:09 +00:00
kyle 96b729bec8 Delete collectors/veeam/Install-VeeamCollector.ps1 2026-10-07 20:09:04 +00:00
kyle ae574fa2b6 Delete README.md 2026-10-07 20:08:58 +00:00
kyle e50711b93b Delete Get-VeeamCollectorDiagnostics.ps1 2026-10-07 20:07:30 +00:00
kyle 8fd17e3817 Upload files to "/" 2026-10-07 20:05:28 +00:00
kyleandClaude Opus 5.5 ba798284df Run the Veeam collector under PowerShell 7
VBR 13's Veeam PowerShell module expects PowerShell 7, but the scheduled
task ran powershell.exe (Windows PowerShell 5.1).

- The installer finds a machine-wide pwsh.exe (Program Files, then
  PATH, or -PwshPath), checks it reports major version 7+, and rejects
  Microsoft Store installs, which SYSTEM can't run. All checks run
  before anything is changed
- The scheduled task and printed next steps use that pwsh.exe

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 14:04:42 -06:00
kyleandClaude Opus 5.5 9ae93cfbc2 Report Veeam backup copy job sessions
VBR 12+ backup copy jobs showed as Unknown. Get-VBRJob returns the copy
(policy) job, but Get-VBRBackupSession only returns sessions of its
hidden worker jobs, whose JobId matches no listed job, so they were
dropped. Confirmed on a VBR 13.0.2 server with the new diagnostics
script.

- File worker sessions under their policy job via
  Session.GetJob().PolicyJobId (looked up once per worker job), and
  record the source job in details.copied_from
- Add Get-VeeamCollectorDiagnostics.ps1, a read-only report of jobs,
  unmatched sessions and their link properties
- Mock: backup copy policy/worker case and a session from a deleted job

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 14:01:56 -06:00
kyle 986cf3be88 Upload files to "collectors/veeam" 2026-10-07 19:42:01 +00:00
kyle 17ee303670 Update scheduled task time to one hour from 15 minutes 2026-10-07 15:57:49 +00:00
kyle effafc5fa0 Delete directory 'test' 2026-10-07 15:56:47 +00:00
kyle 2318579baa Update test/testfolder 2026-10-07 15:56:26 +00:00
kyle 1f02c1c84b Add testfolder 2026-10-07 15:55:31 +00:00
kyle 3b1aee7016 Update scheduled task to use Powershell 7
Todo: add logic to check for and install Powershell 7
2026-10-07 15:48:27 +00:00
kyle d3ab05416d Update manage.py 2026-10-07 01:36:10 +00:00
kyleandClaude Opus 5.5 728b5771cc Add Veeam Backup & Replication collector
PowerShell script for the VBR server, run as a SYSTEM scheduled task.
Each run reports every Get-VBRJob job's next scheduled run and any
sessions from the lookback window (default 48h) not yet delivered in
their final state; running sessions are resent until they finish.

- Session result maps to success/warning/failed; non-Stopped states
  report as running; message combines the session reason, objects that
  didn't succeed, and warning/error lines from the session log
- Delivered sessions are tracked in a state file; failures are retried
  next run, and a rejected API key stops the run immediately
- Install-VeeamCollector.ps1 writes an ACL-restricted config, copies
  the script to Program Files and registers the task
- tests/MockVeeam.ps1 provides stand-in cmdlets for testing without VBR

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 19:30:48 -06:00
kyleandClaude Opus 5.5 f55a44f2e6 Make manage.py use the service's database
manage.py only found the database via BACKUP_DASH_DB, which is set in
the systemd unit and not in an admin's shell, so it silently created a
new backup-dashboard.db in the current directory. API keys created
there were never seen by the service.

- Settings now come from /etc/backup-dashboard/backup-dashboard.env,
  which both the systemd unit (EnvironmentFile=) and manage.py read;
  real environment variables still take precedence
- manage.py prints the database it uses and refuses to create a new
  one unless --create is passed; --db overrides the path

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 19:30:48 -06:00
kyle b8452e1fbb Upload files to "/" 2026-10-06 00:55:03 +00:00
kyle d12e00f69e Update collectors/veeam/README.md 2026-10-06 00:54:51 +00:00
kyle 33c1613ef9 Update collectors/vveam/README.md 2026-10-06 00:54:38 +00:00
kyle 8e29b9cbc4 Update collectors/veeam/Install-VeeamCollector.ps1 2026-10-06 00:54:26 +00:00
kyle 49bf9ef094 Update collectors/vveam/Install-VeeamCollector.ps1 2026-10-06 00:54:13 +00:00
kyle 4d183da7bd Update collectors/veeam/Send-VeeamBackupStatus.ps1 2026-10-06 00:53:47 +00:00
kyle 25f7717ace Update collectors/veeam/MockVeeam.ps1 2026-10-06 00:53:34 +00:00
kyle be23e03c5f Upload files to "/" 2026-10-06 00:53:21 +00:00
kyle 65c587da0d Delete format.js 2026-10-05 18:05:01 +00:00
kyle 29f961f80e Delete config.js 2026-10-05 18:04:49 +00:00
kyle 02789b1910 Update deploy/nginx.conf 2026-10-05 18:03:49 +00:00
kyle 54a6c0b3d6 Update deploy/backup-dashboard-api.service 2026-10-05 18:02:38 +00:00
kyle f11345bb0f Upload files to "backend" 2026-10-05 04:03:26 +00:00
kyle 7217b3e9a8 Upload files to "backend/app" 2026-10-05 04:03:05 +00:00
kyle 47ec7dc9a7 Upload files to "backend/app" 2026-10-05 04:02:57 +00:00
kyle 15ea9e0fec Update backend/app/__init__.py 2026-10-05 04:02:44 +00:00
kyle fe4bd6827c Upload files to "backend" 2026-10-05 04:02:31 +00:00
kyle 4e57eb1a46 Update backend/tests/test_api.py 2026-10-05 04:02:13 +00:00
kyle 670994b937 Update backend/tests/__init__.py 2026-10-05 04:02:03 +00:00
kyle a7c3169606 Upload files to "backend" 2026-10-05 04:01:49 +00:00
kyle 938a0a2af0 Upload files to "backend" 2026-10-05 04:01:38 +00:00
kyle f7e029db8a Upload files to "public/js" 2026-10-05 04:01:12 +00:00
kyle 2df1a75fd5 Update public/css/styles.css 2026-10-05 04:00:56 +00:00
kyle 4d8ee209a5 Update public/index.html 2026-10-05 04:00:38 +00:00
kyle 6c486fbaf3 Update public/js/api.js 2026-10-05 04:00:29 +00:00
kyle fdc7deff16 Upload files to "/" 2026-10-05 04:00:12 +00:00
kyle 0391807027 Upload files to "/" 2026-10-05 03:59:41 +00:00
kyle b0c560377b Update backend/requirements.txt 2026-10-05 03:58:42 +00:00
kyle 17346cc058 Update backend/requirements-dev.txt 2026-10-05 03:58:28 +00:00
kyle 65eb3c88f3 Update backend/manage.py 2026-10-05 03:58:08 +00:00
kyle 996fe33a40 initial upload 2026-10-05 03:57:48 +00:00
kyleandClaude Opus 5.5 bf752a904f Add backend API, running status and 30-day run history
- FastAPI + SQLite backend: tenants, jobs and runs, with an idempotent
  /api/v1/ingest endpoint authenticated by per-collector API keys
- Runs store started_at and finished_at; "Last Run" is the most recent
  finish, and in-progress runs show as Running
- Job modal shows a 30-day daily strip, success rate, and a filterable
  run list with per-run messages
- nginx: HTTPS plus basic auth, ingest endpoint exempt; systemd unit
- manage.py for API keys, job cleanup and demo data; pytest suite

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 21:53:24 -06:00
kyle 44e3065efa Delete app/styles.css 2026-10-05 03:22:51 +00:00
kyle f9bc0f6aeb Delete app/server.js 2026-10-05 03:22:46 +00:00
kyle 153f6ab8e7 Delete app/script.js 2026-10-05 03:22:43 +00:00
kyle ee258d1550 Delete app/index.html 2026-10-05 03:22:38 +00:00
kyle 4b4e6b84d0 Delete docker-compose.yml 2026-10-05 03:22:28 +00:00
kyle 889aa4f000 Delete dockerfile 2026-10-05 03:22:23 +00:00
kyle 483cc7d50d Delete LICENSE 2026-10-05 03:22:19 +00:00
58 changed files with 5328 additions and 494 deletions
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.venv/
venv/
.pytest_cache/
*.db
*.db-wal
*.db-shm
-232
View File
@@ -1,232 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
Backup-Dashboard
Copyright (C) 2026 kyle
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
Backup-Dashboard Copyright (C) 2026 kyle
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
+153 -1
View File
@@ -1,3 +1,155 @@
# Backup-Dashboard
Proof of concept backup dashboard, intended to display backup jobs from multiple sources
Proof of concept backup dashboard, intended to display backup jobs from multiple sources.
```
collectors ──POST /api/v1/ingest (API key)──▶ ┌──────────────┐
│ FastAPI + │
browser ──nginx (TLS)──▶ /api/* ─────────────▶│ SQLite │
│ └─ static files: public/ └──────────────┘
└─ auth_request ─▶ oauth2-proxy ─▶ Microsoft Entra ID (Microsoft 365 sign-in)
```
- `public/`: static frontend (no build step). Add `?mock=1` to the URL to use built-in sample data.
- `backend/`: FastAPI API backed by a single SQLite file.
- `deploy/`: nginx site and systemd unit.
- `collectors/`: PowerShell scripts that report results from backup products ([Veeam B&R](collectors/veeam/README.md), [Azure Backup / MARS](collectors/mars/README.md), [Windows Server Backup](collectors/wsb/README.md), [Veeam Backup for M365](collectors/vb365/README.md)). Code they share (config, logging, posting, state, installing) is in `collectors/common/BackupDashboard.Collector.psm1`.
## Data model
- **Tenant** (company) → **Job** → **Run**.
- A job is keyed by `(tenant, source_system, job.external_id)`, so renaming a job at the source updates it instead of creating a duplicate.
- A run has `status` (`running` | `success` | `warning` | `failed`), `started_at` and `finished_at`.
The dashboard's **Last Run** is the most recent `finished_at`. While a job is running it shows
**Running**, and Last Run still shows when the previous run finished.
- Runs older than `BACKUP_DASH_RETENTION_DAYS` (default 90, minimum 30) are purged every 6 hours.
## Reporting results (collectors)
Each collector (a script on a Veeam server, a webhook relay, etc.) gets its own API key:
```bash
cd backend && python manage.py create-key veeam-acme
```
Then it POSTs one report per run. Send `running` when a run starts (optional) and the final status
when it ends. Reports with the same `started_at` (or `run.external_id`) update the same run, and
retries are safe.
```bash
curl -X POST https://backups.example.com/api/v1/ingest \
-H "Authorization: Bearer bdk_..." -H "Content-Type: application/json" \
-d '{
"tenant": "Acme Corp",
"source_system": "Veeam B&R",
"job": { "external_id": "4f1c…", "name": "File Server – Nightly", "next_run_at": "2026-10-05T22:00:00-04:00" },
"run": {
"status": "success",
"started_at": "2026-10-04T22:00:05-04:00",
"finished_at": "2026-10-04T22:47:12-04:00",
"message": "Job completed successfully.",
"size_bytes": 128849018880,
"details": { "repository": "REPO-01" }
}
}'
```
- All timestamps must include a timezone offset (`Get-Date -Format o` in PowerShell does this).
- `job.next_run_at` controls the **Overdue** flag. If you omit it, the stored value is kept. If you send `null`, it's cleared.
- `run` can be omitted to only register or update a job.
- Unknown fields are rejected (HTTP 422), so typos in collector scripts fail loudly.
## API (read)
| Endpoint | Returns |
|---|---|
| `GET /api/v1/jobs` | All jobs with current status, `running_since`, `last_run_at`, `next_run_at` |
| `GET /api/v1/jobs/{id}` | One job, plus the last finished run's duration, size, message and details |
| `GET /api/v1/jobs/{id}/runs?days=30` | Run history (no messages), newest first |
| `GET /api/v1/runs/{id}` | One run including message and details |
| `GET /api/v1/health` | `{"status": "ok"}` |
The OpenAPI schema is at `/api/v1/openapi.json`.
## Authentication
- **Dashboard users:** Microsoft 365 sign-in. nginx checks every request with
[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/), which signs users in with Microsoft
Entra ID. Who may sign in is controlled in Entra (users or a group assigned to the app). Every
signed-in user can see every tenant. Sessions last 12 hours; signing in again is usually silent.
The dashboard shows who's signed in; **Sign out** also signs out of Microsoft in that browser,
otherwise the next visit would sign straight back in.
- **Collectors:** per-collector bearer keys, checked by the app. Only a SHA-256 hash is stored.
To revoke one, run `python manage.py revoke-key NAME`. `/api/v1/ingest` is the only path that
doesn't need Microsoft sign-in.
### Microsoft 365 sign-in setup
**In the Microsoft Entra admin center** (entra.microsoft.com), as an admin of your tenant:
1. *App registrations → New registration*. Name: `Backup Dashboard`. Supported account types:
**Accounts in this organizational directory only** (single tenant). Redirect URI: platform
**Web**, `https://<your dashboard host>/oauth2/callback`. Register.
2. On its *Overview*, note the **Application (client) ID** and **Directory (tenant) ID**.
3. *Certificates & secrets → New client secret*. Copy the secret's **Value** now (it's only shown
once). Note its expiry date: sign-in stops working when it expires, so set a reminder to
create a new one before then.
4. *Enterprise applications → Backup Dashboard → Properties*: set **Assignment required** to
**Yes**. Then *Users and groups → Add user/group* and add the people (or a group) who should
see the dashboard. Everyone else in the tenant is refused by Microsoft before reaching it.
No API permissions beyond the default `User.Read` are needed.
**On the dashboard server:**
1. Install oauth2-proxy 7.8 or newer from its
[releases](https://github.com/oauth2-proxy/oauth2-proxy/releases) as `/usr/local/bin/oauth2-proxy`,
and create its user: `sudo useradd --system --no-create-home oauth2-proxy`.
2. Install the config and the client secret, readable only by root and oauth2-proxy:
```bash
sudo install -d -m 750 -g oauth2-proxy /etc/oauth2-proxy
sudo install -m 640 -g oauth2-proxy deploy/oauth2-proxy.cfg /etc/oauth2-proxy/backup-dashboard.cfg
sudo sh -c 'umask 027; printf %s "<client secret value>" > /etc/oauth2-proxy/client-secret' && sudo chgrp oauth2-proxy /etc/oauth2-proxy/client-secret
```
In `/etc/oauth2-proxy/backup-dashboard.cfg`, fill in `<TENANT_ID>`, `<APPLICATION_CLIENT_ID>`,
`<DASHBOARD_HOST>`, and `<COOKIE_SECRET>`, generated with:
`openssl rand -base64 32 | tr -- '+/' '-_'`
3. Install `deploy/oauth2-proxy.service` into `/etc/systemd/system/`, then
`sudo systemctl daemon-reload && sudo systemctl enable --now oauth2-proxy`.
Check it with `journalctl -u oauth2-proxy -n 20`: it should say "OAuthProxy configured for
Microsoft Entra ID".
4. Install the new `deploy/nginx.conf` (keeping your server name and certificate paths), then
`sudo nginx -t && sudo systemctl reload nginx`. Basic auth and its htpasswd file are no longer
used.
**Troubleshooting:** sign-in problems show in `journalctl -u oauth2-proxy`. Microsoft's own
errors (wrong redirect URI, user not assigned, expired secret) appear on its sign-in page with an
`AADSTS…` code.
## Development
```bash
cd backend
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt -r requirements-dev.txt
pytest
python manage.py --create seed-demo # 30 days of fake history in ./backup-dashboard.db
BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload
# → http://localhost:8000
```
## Deployment (Linux, nginx)
1. Copy the repo to `/opt/backup-dashboard`, create a venv at `/opt/backup-dashboard/venv`, and run `pip install -r backend/requirements.txt`.
2. Copy `deploy/backup-dashboard.env` to `/etc/backup-dashboard/backup-dashboard.env`. Both the service and `manage.py` read their settings from it.
3. Create a `backupdash` system user, then install `deploy/backup-dashboard-api.service` into `/etc/systemd/system/` and enable it. The database is created on first start.
4. Copy `public/` to `/var/www/backup-dashboard/public`, install `deploy/nginx.conf`, get a certificate with certbot, and set up Microsoft 365 sign-in (see [Microsoft 365 sign-in setup](#microsoft-365-sign-in-setup)).
5. Run `manage.py` as the service user. It finds the database through the env file:
```bash
cd /opt/backup-dashboard/backend
sudo -u backupdash /opt/backup-dashboard/venv/bin/python manage.py create-key veeam-acme
```
It prints the database path it's using and won't create a new database unless you pass `--create`.
Back up the SQLite file with `sqlite3 backup-dashboard.db ".backup copy.db"`. Don't use a plain `cp`
while the service is running.
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Backup Dashboard</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="dashboard">
<h1>Client Backup Dashboard</h1>
<div id="backup-container"></div>
</div>
<script src="script.js"></script>
</body>
</html>
-66
View File
@@ -1,66 +0,0 @@
const container = document.getElementById('backup-container');
fetch('http://localhost:3000/api/backups')
.then((response) => response.json())
.then((data) => {
let currentCompany = '';
let currentProduct = '';
data.forEach((item) => {
// Group by company
if (item.company_name !== currentCompany) {
currentCompany = item.company_name;
const companySection = document.createElement('div');
companySection.className = 'company-section';
const header = document.createElement('h2');
header.className = 'company-header';
header.textContent = currentCompany;
companySection.appendChild(header);
container.appendChild(companySection);
}
// Group by backup product
if (item.backup_product !== currentProduct) {
currentProduct = item.backup_product;
const productSection = document.createElement('div');
productSection.className = 'backup-product';
const productHeader = document.createElement('h3');
productHeader.textContent = currentProduct;
productSection.appendChild(productHeader);
container.appendChild(productSection);
}
const jobItem = document.createElement('div');
jobItem.className = 'job-item';
jobItem.innerHTML = `
<span class="job-name">${item.job_name}</span>
<span class="last-backup">Last Backup: ${new Date(item.last_backup_time).toLocaleString()}</span>
<span class="status-pill status-${item.status}" data-detail="${item.error_details}">${item.status}</span>
`;
// Click to show error details
const statusPill = jobItem.querySelector('.status-pill');
statusPill.addEventListener('click', () => {
const detailModal = document.createElement('div');
detailModal.className = 'detail-modal';
detailModal.innerHTML = `
<span class="modal-close">&times;</span>
<h4>Error Details</h4>
<p>${item.error_details}</p>
`;
document.body.appendChild(detailModal);
const closeModal = detailModal.querySelector('.modal-close');
closeModal.addEventListener('click', () => {
document.body.removeChild(detailModal);
});
});
const productSection = container.lastElementChild;
productSection.appendChild(jobItem);
});
})
.catch((error) => {
console.error('Error fetching data:', error);
container.innerHTML = '<p>Error loading backup data. Check backend logs.</p>';
});
-37
View File
@@ -1,37 +0,0 @@
const express = require('express');
const { Pool } = require('pg');
const app = express();
const port = 3000;
const pool = new Pool({
user: 'your_user',
host: 'localhost',
database: 'backup_dashboard',
password: 'your_password',
port: 5432,
});
app.get('/api/backups', async (req, res) => {
try {
const result = await pool.query(`
SELECT
company_name,
backup_product,
job_name,
last_backup_time,
status,
error_details,
next_backup_time
FROM backup_jobs
ORDER BY company_name, backup_product;
`);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).send('Server error');
}
});
app.listen(port, () => {
console.log(`Backend running on http://localhost:${port}`);
});
-95
View File
@@ -1,95 +0,0 @@
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #f4f6f8;
}
.dashboard {
max-width: 1200px;
margin: auto;
}
.company-section {
margin-bottom: 40px;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.company-header {
font-size: 24px;
margin-bottom: 15px;
}
.backup-product {
margin-bottom: 20px;
padding: 10px;
background: #e8f0fe;
border-radius: 5px;
}
.job-list {
margin-left: 20px;
}
.job-item {
display: flex;
justify-content: space-between;
padding: 8px;
margin-bottom: 5px;
background: #fff;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.job-name {
font-weight: bold;
}
.status-pill {
padding: 4px 8px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-warning {
background: #fff3cd;
color: #856404;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
.detail-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
z-index: 1000;
display: none;
}
.detail-modal.show {
display: block;
}
.modal-close {
position: absolute;
top: 10px;
right: 15px;
font-size: 20px;
cursor: pointer;
}
+8
View File
@@ -0,0 +1,8 @@
__pycache__/
*.pyc
.venv/
venv/
.pytest_cache/
*.db
*.db-wal
*.db-shm
View File
+42
View File
@@ -0,0 +1,42 @@
"""Collector (machine) authentication.
Dashboard *users* are authenticated by nginx in front of the app (Microsoft 365 sign-in via
oauth2-proxy), so nothing here deals with people.
"""
import hashlib
import secrets
import sqlite3
from fastapi import Depends, Header, HTTPException
from .db import get_db
from .timeutil import to_db, utcnow
KEY_PREFIX = "bdk_"
def generate_key() -> str:
return KEY_PREFIX + secrets.token_urlsafe(32)
def hash_key(key: str) -> str:
# Keys are 256-bit random, so a fast unsalted hash is fine (no dictionary to attack).
return hashlib.sha256(key.encode()).hexdigest()
def require_ingest_key(
authorization: str | None = Header(None),
conn: sqlite3.Connection = Depends(get_db),
) -> str:
scheme, _, token = (authorization or "").partition(" ")
token = token.strip()
if scheme.lower() != "bearer" or not token:
raise HTTPException(401, "Missing bearer token", headers={"WWW-Authenticate": "Bearer"})
row = conn.execute(
"SELECT id, name FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL", (hash_key(token),)
).fetchone()
if row is None:
raise HTTPException(401, "Invalid or revoked API key", headers={"WWW-Authenticate": "Bearer"})
conn.execute("UPDATE api_keys SET last_used_at = ? WHERE id = ?", (to_db(utcnow()), row["id"]))
return row["name"]
+99
View File
@@ -0,0 +1,99 @@
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from fastapi import Request
SCHEMA_VERSION = 1
SCHEMA = """
CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
);
-- A job is identified by (tenant, source system, the source's own job id), so two
-- products can't collide and renaming a job in Veeam doesn't create a duplicate.
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
source_system TEXT NOT NULL,
external_id TEXT NOT NULL,
name TEXT NOT NULL,
next_run_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (tenant_id, source_system, external_id)
);
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
external_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('running', 'success', 'warning', 'failed')),
started_at TEXT NOT NULL,
finished_at TEXT,
message TEXT NOT NULL DEFAULT '',
size_bytes INTEGER,
details TEXT NOT NULL DEFAULT '{}',
received_at TEXT NOT NULL,
CHECK ((status = 'running') = (finished_at IS NULL)),
UNIQUE (job_id, external_id)
);
CREATE INDEX IF NOT EXISTS runs_job_started ON runs (job_id, started_at);
CREATE INDEX IF NOT EXISTS runs_job_finished ON runs (job_id, finished_at);
-- Keys used by collectors to push results. Only the SHA-256 is stored.
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
key_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
);
"""
def connect(path: Path | str) -> sqlite3.Connection:
# Autocommit mode; writes that must be atomic go through transaction().
# check_same_thread=False: FastAPI may run a dependency and its endpoint on different
# threadpool threads, but a connection is only ever used by one request at a time.
conn = sqlite3.connect(path, timeout=10, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA busy_timeout = 10000")
return conn
def init_db(path: Path | str) -> None:
conn = connect(path)
try:
conn.execute("PRAGMA journal_mode = WAL")
version = conn.execute("PRAGMA user_version").fetchone()[0]
if version > SCHEMA_VERSION:
raise RuntimeError(f"Database schema v{version} is newer than this app (v{SCHEMA_VERSION})")
conn.executescript(SCHEMA)
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
finally:
conn.close()
@contextmanager
def transaction(conn: sqlite3.Connection) -> Iterator[None]:
conn.execute("BEGIN IMMEDIATE")
try:
yield
except BaseException:
conn.execute("ROLLBACK")
raise
conn.execute("COMMIT")
def get_db(request: Request) -> Iterator[sqlite3.Connection]:
conn = connect(request.app.state.settings.db_path)
try:
yield conn
finally:
conn.close()
+128
View File
@@ -0,0 +1,128 @@
import asyncio
import logging
import sqlite3
from contextlib import asynccontextmanager
from datetime import timedelta
from pathlib import Path
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
from . import db, repo
from .auth import require_ingest_key
from .models import IngestIn
from .settings import HISTORY_DAYS, Settings, load_settings
from .timeutil import to_db, utcnow
log = logging.getLogger("backup_dashboard")
PURGE_INTERVAL_SECONDS = 6 * 3600
router = APIRouter(prefix="/api/v1")
@router.get("/health")
def health(conn: sqlite3.Connection = Depends(db.get_db)):
conn.execute("SELECT 1")
return {"status": "ok"}
@router.get("/me")
def me(request: Request):
# Set by nginx from oauth2-proxy's sign-in check (nginx overwrites anything the browser sends).
# Display only: access control happens in nginx. Empty in dev or without Microsoft sign-in.
return {
"email": request.headers.get("x-forwarded-email") or None,
"name": request.headers.get("x-forwarded-preferred-username") or request.headers.get("x-forwarded-user") or None,
}
@router.get("/jobs")
def list_jobs(conn: sqlite3.Connection = Depends(db.get_db)):
return {"generated_at": to_db(utcnow()), "jobs": repo.list_jobs(conn)}
@router.get("/jobs/{job_id}")
def get_job(job_id: int, conn: sqlite3.Connection = Depends(db.get_db)):
job = repo.get_job(conn, job_id)
if job is None:
raise HTTPException(404, "Job not found")
return job
@router.get("/jobs/{job_id}/runs")
def list_runs(
job_id: int,
days: int = Query(HISTORY_DAYS, ge=1, le=HISTORY_DAYS),
conn: sqlite3.Connection = Depends(db.get_db),
):
if conn.execute("SELECT 1 FROM jobs WHERE id = ?", (job_id,)).fetchone() is None:
raise HTTPException(404, "Job not found")
since = utcnow() - timedelta(days=days)
return {"job_id": job_id, "since": to_db(since), "runs": repo.list_runs(conn, job_id, since)}
@router.get("/runs/{run_id}")
def get_run(run_id: int, conn: sqlite3.Connection = Depends(db.get_db)):
run = repo.get_run(conn, run_id)
if run is None:
raise HTTPException(404, "Run not found")
return run
@router.post("/ingest")
def ingest(
item: IngestIn,
key_name: str = Depends(require_ingest_key),
conn: sqlite3.Connection = Depends(db.get_db),
):
job_id, run_id = repo.ingest(conn, item)
log.info("ingest key=%s tenant=%r job=%r run=%s", key_name, item.tenant, item.job.name, run_id)
return {"job_id": job_id, "run_id": run_id}
async def _purge_loop(settings: Settings) -> None:
def purge() -> int:
conn = db.connect(settings.db_path)
try:
return repo.purge_old_runs(conn, settings.retention_days)
finally:
conn.close()
while True:
try:
if deleted := await asyncio.to_thread(purge):
log.info("purged %d runs older than %d days", deleted, settings.retention_days)
except Exception:
log.exception("run purge failed")
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or load_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
db.init_db(settings.db_path)
task = asyncio.create_task(_purge_loop(settings))
try:
yield
finally:
task.cancel()
# Interactive docs are off: nginx's strict CSP would block their CDN assets anyway.
app = FastAPI(
title="Backup Dashboard API",
lifespan=lifespan,
docs_url=None,
redoc_url=None,
openapi_url="/api/v1/openapi.json",
)
app.state.settings = settings
app.include_router(router)
if settings.serve_static:
from fastapi.staticfiles import StaticFiles
public = Path(__file__).resolve().parents[2] / "public"
app.mount("/", StaticFiles(directory=public, html=True), name="static")
return app
+63
View File
@@ -0,0 +1,63 @@
import json
from typing import Annotated, Any, Literal
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, StringConstraints, field_validator, model_validator
RunStatus = Literal["running", "success", "warning", "failed"]
Name = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=200)]
MAX_MESSAGE_CHARS = 32_000
MAX_DETAILS_BYTES = 16_000
class Strict(BaseModel):
# Reject unknown fields so a typo in a collector script fails loudly instead of being dropped.
model_config = ConfigDict(extra="forbid")
class JobIn(Strict):
external_id: Name = Field(description="The source system's own stable ID for the job")
name: Name
# Omit to leave the stored value untouched; send null to clear it.
next_run_at: AwareDatetime | None = None
class RunIn(Strict):
external_id: Name | None = Field(
None, description="The source's ID for this run. Defaults to started_at, so a 'running' "
"report and its later completion report merge into one run.")
status: RunStatus
started_at: AwareDatetime
# Omit for a finished run to use the time the report was received.
finished_at: AwareDatetime | None = None
message: str = ""
size_bytes: int | None = Field(None, ge=0)
details: dict[str, Any] = Field(default_factory=dict)
@field_validator("message")
@classmethod
def truncate_message(cls, v: str) -> str:
# Backup logs can be huge; keep the head, which is where the summary usually is.
return v if len(v) <= MAX_MESSAGE_CHARS else v[:MAX_MESSAGE_CHARS] + "\n… [truncated]"
@field_validator("details")
@classmethod
def limit_details(cls, v: dict[str, Any]) -> dict[str, Any]:
if len(json.dumps(v)) > MAX_DETAILS_BYTES:
raise ValueError(f"details must serialize to at most {MAX_DETAILS_BYTES} bytes")
return v
@model_validator(mode="after")
def check_times(self) -> "RunIn":
if self.status == "running" and self.finished_at is not None:
raise ValueError("a running run cannot have finished_at")
if self.finished_at is not None and self.finished_at < self.started_at:
raise ValueError("finished_at is before started_at")
return self
class IngestIn(Strict):
tenant: Name = Field(description="Company name as shown on the dashboard")
source_system: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)]
job: JobIn
run: RunIn | None = Field(None, description="Omit to only register/update the job (e.g. next_run_at)")
+162
View File
@@ -0,0 +1,162 @@
import json
import sqlite3
from datetime import datetime, timedelta
from typing import Any
from .db import transaction
from .models import IngestIn
from .timeutil import to_db, utcnow
# One row per job: `cur` is the most recent run (may be in progress), `fin` the most recent
# finished one. "Last run" on the dashboard is fin.finished_at, so a job that's currently
# running still shows when its previous run completed.
_JOB_SQL = """
SELECT j.id, t.name AS company_name, j.name AS job_name, j.source_system, j.next_run_at,
cur.status,
CASE WHEN cur.status = 'running' THEN cur.started_at END AS running_since,
fin.status AS last_status,
fin.started_at AS last_started_at,
fin.finished_at AS last_run_at,
unixepoch(fin.finished_at) - unixepoch(fin.started_at) AS duration_seconds,
fin.size_bytes,
fin.details,
COALESCE(fin.message, cur.message, '') AS message
FROM jobs j
JOIN tenants t ON t.id = j.tenant_id
LEFT JOIN runs cur ON cur.id = (
SELECT r.id FROM runs r WHERE r.job_id = j.id
ORDER BY r.started_at DESC, r.id DESC LIMIT 1)
LEFT JOIN runs fin ON fin.id = (
SELECT r.id FROM runs r WHERE r.job_id = j.id AND r.finished_at IS NOT NULL
ORDER BY r.finished_at DESC, r.id DESC LIMIT 1)
"""
_SUMMARY_FIELDS = (
"id", "company_name", "job_name", "source_system", "status",
"running_since", "last_run_at", "next_run_at", "message",
)
def list_jobs(conn: sqlite3.Connection) -> list[dict[str, Any]]:
rows = conn.execute(_JOB_SQL + " ORDER BY t.name, j.name").fetchall()
return [{k: row[k] for k in _SUMMARY_FIELDS} for row in rows]
def get_job(conn: sqlite3.Connection, job_id: int) -> dict[str, Any] | None:
row = conn.execute(_JOB_SQL + " WHERE j.id = ?", (job_id,)).fetchone()
if row is None:
return None
job = dict(row)
job["details"] = json.loads(job["details"]) if job["details"] else {}
return job
def list_runs(conn: sqlite3.Connection, job_id: int, since: datetime) -> list[dict[str, Any]]:
# Messages/details are left out: an hourly job has ~720 runs a month and logs can be large.
# The UI fetches a single run's full record on demand.
rows = conn.execute(
"""
SELECT id, status, started_at, finished_at,
unixepoch(finished_at) - unixepoch(started_at) AS duration_seconds, size_bytes
FROM runs WHERE job_id = ? AND started_at >= ?
ORDER BY started_at DESC, id DESC
""",
(job_id, to_db(since)),
).fetchall()
return [dict(r) for r in rows]
def get_run(conn: sqlite3.Connection, run_id: int) -> dict[str, Any] | None:
row = conn.execute(
"""
SELECT id, job_id, status, started_at, finished_at,
unixepoch(finished_at) - unixepoch(started_at) AS duration_seconds,
size_bytes, message, details
FROM runs WHERE id = ?
""",
(run_id,),
).fetchone()
if row is None:
return None
run = dict(row)
run["details"] = json.loads(run["details"])
return run
def ingest(conn: sqlite3.Connection, item: IngestIn) -> tuple[int, int | None]:
"""Upsert tenant → job → run. Safe to call repeatedly with the same payload."""
now = utcnow()
now_s = to_db(now)
with transaction(conn):
conn.execute("INSERT INTO tenants (name) VALUES (?) ON CONFLICT (name) DO NOTHING", (item.tenant,))
tenant_id = conn.execute("SELECT id FROM tenants WHERE name = ?", (item.tenant,)).fetchone()[0]
job_id = conn.execute(
"""
INSERT INTO jobs (tenant_id, source_system, external_id, name, next_run_at, created_at, updated_at)
VALUES (:tenant_id, :source, :ext, :name, :next_run, :now, :now)
ON CONFLICT (tenant_id, source_system, external_id) DO UPDATE SET
name = excluded.name,
next_run_at = CASE WHEN :set_next_run THEN excluded.next_run_at ELSE jobs.next_run_at END,
updated_at = excluded.updated_at
RETURNING id
""",
{
"tenant_id": tenant_id,
"source": item.source_system,
"ext": item.job.external_id,
"name": item.job.name,
"next_run": to_db(item.job.next_run_at) if item.job.next_run_at else None,
"set_next_run": "next_run_at" in item.job.model_fields_set,
"now": now_s,
},
).fetchone()[0]
if item.run is None:
return job_id, None
run = item.run
started = to_db(run.started_at)
finished = None
if run.status != "running":
finished = to_db(run.finished_at or max(now, run.started_at))
params = {
"job_id": job_id,
"ext": run.external_id or started,
"status": run.status,
"started": started,
"finished": finished,
"message": run.message,
"size": run.size_bytes,
"details": json.dumps(run.details),
"now": now_s,
}
# The WHERE stops a late/retried "running" report from reopening a run that already finished.
row = conn.execute(
"""
INSERT INTO runs (job_id, external_id, status, started_at, finished_at, message,
size_bytes, details, received_at)
VALUES (:job_id, :ext, :status, :started, :finished, :message, :size, :details, :now)
ON CONFLICT (job_id, external_id) DO UPDATE SET
status = excluded.status,
started_at = excluded.started_at,
finished_at = excluded.finished_at,
message = excluded.message,
size_bytes = excluded.size_bytes,
details = excluded.details,
received_at = excluded.received_at
WHERE NOT (runs.status != 'running' AND excluded.status = 'running')
RETURNING id
""",
params,
).fetchone()
if row is None:
row = conn.execute(
"SELECT id FROM runs WHERE job_id = :job_id AND external_id = :ext", params
).fetchone()
return job_id, row[0]
def purge_old_runs(conn: sqlite3.Connection, retention_days: int) -> int:
cutoff = to_db(utcnow() - timedelta(days=retention_days))
return conn.execute("DELETE FROM runs WHERE started_at < ?", (cutoff,)).rowcount
+43
View File
@@ -0,0 +1,43 @@
import os
from dataclasses import dataclass
from pathlib import Path
# The modal shows this many days of run history; retention can never be shorter.
HISTORY_DAYS = 30
# Shared by the systemd service (EnvironmentFile=) and manage.py, so both always use the same DB.
DEFAULT_ENV_FILE = "/etc/backup-dashboard/backup-dashboard.env"
@dataclass(frozen=True)
class Settings:
db_path: Path
retention_days: int = 90
# Dev only: serve ../public from the API so you can run without nginx.
serve_static: bool = False
def read_env_file(path: Path) -> dict[str, str]:
"""Parse simple KEY=VALUE lines (the subset systemd's EnvironmentFile accepts)."""
values = {}
try:
lines = path.read_text().splitlines()
except FileNotFoundError:
return values
for line in lines:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip("\"'")
return values
def load_settings() -> Settings:
# Real environment variables win over the env file; the env file wins over dev defaults.
env = read_env_file(Path(os.environ.get("BACKUP_DASH_ENV_FILE", DEFAULT_ENV_FILE))) | os.environ
return Settings(
db_path=Path(env.get("BACKUP_DASH_DB", "backup-dashboard.db")),
retention_days=max(HISTORY_DAYS, int(env.get("BACKUP_DASH_RETENTION_DAYS", "90"))),
serve_static=env.get("BACKUP_DASH_SERVE_STATIC") == "1",
)
+12
View File
@@ -0,0 +1,12 @@
from datetime import UTC, datetime
# Every timestamp in the DB uses this exact format, so string comparison == chronological comparison.
DB_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
def utcnow() -> datetime:
return datetime.now(UTC).replace(microsecond=0)
def to_db(dt: datetime) -> str:
return dt.astimezone(UTC).strftime(DB_FORMAT)
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Admin CLI. Finds the database the same way the API does: BACKUP_DASH_DB from the environment,
else from /etc/backup-dashboard/backup-dashboard.env, else ./backup-dashboard.db (dev).
Run it as the service user so files it creates stay writable by the API:
sudo -u backupdash /opt/backup-dashboard/venv/bin/python manage.py create-key veeam-acme
python manage.py create-key veeam-acme # prints the key once
python manage.py list-keys
python manage.py revoke-key veeam-acme
python manage.py list-jobs
python manage.py delete-job 12 # e.g. a decommissioned job stuck as Overdue
python manage.py seed-demo # 30 days of fake history for trying the UI
python manage.py --db ./dev.db --create seed-demo
"""
import argparse
import random
import sys
from datetime import timedelta
from pathlib import Path
from app import db, repo
from app.auth import generate_key, hash_key
from app.models import IngestIn
from app.settings import DEFAULT_ENV_FILE, load_settings
from app.timeutil import to_db, utcnow
def create_key(conn, args):
key = generate_key()
try:
conn.execute(
"INSERT INTO api_keys (name, key_hash, created_at) VALUES (?, ?, ?)",
(args.name, hash_key(key), to_db(utcnow())),
)
except db.sqlite3.IntegrityError:
sys.exit(f"A key named {args.name!r} already exists")
print(f"API key for {args.name!r} (shown once, store it in the collector's config):\n{key}")
def list_keys(conn, args):
for r in conn.execute("SELECT name, created_at, last_used_at, revoked_at FROM api_keys ORDER BY name"):
state = f"revoked {r['revoked_at']}" if r["revoked_at"] else "active"
print(f"{r['name']:<30} {state:<30} created {r['created_at']} last used {r['last_used_at'] or 'never'}")
def revoke_key(conn, args):
cur = conn.execute(
"UPDATE api_keys SET revoked_at = ? WHERE name = ? AND revoked_at IS NULL", (to_db(utcnow()), args.name)
)
sys.exit(0 if cur.rowcount else f"No active key named {args.name!r}")
def list_jobs(conn, args):
for j in repo.list_jobs(conn):
print(f"{j['id']:>5} {j['company_name']:<28} {j['job_name']:<40} {j['status'] or '-':<8} last {j['last_run_at'] or 'never'}")
def delete_job(conn, args):
cur = conn.execute("DELETE FROM jobs WHERE id = ?", (args.job_id,))
sys.exit(0 if cur.rowcount else f"No job with id {args.job_id}")
DEMO = [
("Acme Corp", "Veeam B&R", "File Server - Nightly", 24),
("Acme Corp", "Veeam B&R", "SQL01 - Transaction Logs", 4),
("Bluewater Dental", "Datto", "Workstations - Daily", 24),
("Bluewater Dental", "Datto", "Imaging Server - Hourly", 1),
("Northfield Logistics", "Acronis", "Exchange - Nightly", 24),
]
def seed_demo(conn, args):
rng = random.Random(42)
now = utcnow()
for tenant, source, name, every_h in DEMO:
ext = name.lower().replace(" ", "-")
# Offset so runs don't all line up exactly on the current time
start = now - timedelta(days=30) + timedelta(minutes=rng.randint(5, every_h * 60 - 5))
while start < now:
dur = timedelta(minutes=rng.randint(2, 50 if every_h > 1 else 15))
end = start + dur
status = "running" if end > now else rng.choices(["success", "warning", "failed"], [90, 7, 3])[0]
run = {"status": status, "started_at": start.isoformat()}
if status != "running":
run |= {"finished_at": end.isoformat(), "size_bytes": rng.randint(10**8, 10**11),
"message": {"success": "Job completed successfully.",
"warning": "Completed with warnings: 1 file skipped (locked).",
"failed": "Error: target repository unreachable."}[status]}
repo.ingest(conn, IngestIn.model_validate({
"tenant": tenant, "source_system": source,
"job": {"external_id": ext, "name": name,
"next_run_at": (start + timedelta(hours=every_h)).isoformat()},
"run": run,
}))
start += timedelta(hours=every_h)
print(f"Seeded {len(DEMO)} demo jobs")
def main():
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--db", type=Path, help="database file (overrides BACKUP_DASH_DB and the env file)")
p.add_argument("--create", action="store_true", help="create the database if it doesn't exist")
sub = p.add_subparsers(required=True)
s = sub.add_parser("create-key"); s.add_argument("name"); s.set_defaults(fn=create_key)
s = sub.add_parser("list-keys"); s.set_defaults(fn=list_keys)
s = sub.add_parser("revoke-key"); s.add_argument("name"); s.set_defaults(fn=revoke_key)
s = sub.add_parser("list-jobs"); s.set_defaults(fn=list_jobs)
s = sub.add_parser("delete-job"); s.add_argument("job_id", type=int); s.set_defaults(fn=delete_job)
s = sub.add_parser("seed-demo"); s.set_defaults(fn=seed_demo)
args = p.parse_args()
db_path = args.db or load_settings().db_path
# Refuse to silently create a fresh DB: that almost always means we're looking in the wrong
# place, and anything written there (like a new API key) would be invisible to the service.
if not db_path.exists() and not args.create:
sys.exit(
f"Database not found: {db_path.resolve()}\n"
f"Set BACKUP_DASH_DB in {DEFAULT_ENV_FILE} (or pass --db), "
"or add --create to start a new database there."
)
print(f"Using database {db_path.resolve()}", file=sys.stderr)
db.init_db(db_path)
conn = db.connect(db_path)
try:
args.fn(conn, args)
finally:
conn.close()
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
[pytest]
pythonpath = .
+2
View File
@@ -0,0 +1,2 @@
httpx
pytest
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.115
uvicorn[standard]>=0.30
View File
+130
View File
@@ -0,0 +1,130 @@
from datetime import timedelta
import pytest
from fastapi.testclient import TestClient
from app import db
from app.auth import generate_key, hash_key
from app.main import create_app
from app.settings import Settings
from app.timeutil import to_db, utcnow
@pytest.fixture
def client(tmp_path):
settings = Settings(db_path=tmp_path / "test.db")
with TestClient(create_app(settings)) as c:
key = generate_key()
conn = db.connect(settings.db_path)
conn.execute("INSERT INTO api_keys (name, key_hash, created_at) VALUES ('test', ?, ?)",
(hash_key(key), to_db(utcnow())))
conn.close()
c.headers["Authorization"] = f"Bearer {key}"
yield c
def iso(delta: timedelta) -> str:
return (utcnow() + delta).isoformat()
def payload(run=None, **job):
body = {"tenant": "Acme Corp", "source_system": "Veeam B&R",
"job": {"external_id": "job-1", "name": "File Server", **job}}
if run is not None:
body["run"] = run
return body
def test_ingest_requires_valid_key(client):
assert client.post("/api/v1/ingest", json=payload(), headers={"Authorization": ""}).status_code == 401
assert client.post("/api/v1/ingest", json=payload(), headers={"Authorization": "Bearer nope"}).status_code == 401
def test_running_then_finished_merges_into_one_run(client):
started = iso(-timedelta(hours=1))
r = client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": started}))
assert r.status_code == 200, r.text
job_id = r.json()["job_id"]
job = client.get("/api/v1/jobs").json()["jobs"][0]
assert job["status"] == "running"
assert job["running_since"] is not None
assert job["last_run_at"] is None # nothing has finished yet
finished = iso(-timedelta(minutes=10))
client.post("/api/v1/ingest", json=payload(
{"status": "warning", "started_at": started, "finished_at": finished, "message": "1 file skipped"}))
detail = client.get(f"/api/v1/jobs/{job_id}").json()
assert detail["status"] == "warning"
assert detail["running_since"] is None
assert detail["last_run_at"] == to_db(utcnow().fromisoformat(finished))
assert detail["duration_seconds"] == 50 * 60
assert detail["message"] == "1 file skipped"
assert len(client.get(f"/api/v1/jobs/{job_id}/runs").json()["runs"]) == 1
def test_late_running_report_does_not_reopen_finished_run(client):
started = iso(-timedelta(hours=1))
client.post("/api/v1/ingest", json=payload({"status": "success", "started_at": started, "finished_at": iso(-timedelta(minutes=5))}))
client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": started}))
assert client.get("/api/v1/jobs").json()["jobs"][0]["status"] == "success"
def test_last_run_is_previous_finish_while_new_run_in_progress(client):
prev_end = iso(-timedelta(hours=23))
client.post("/api/v1/ingest", json=payload({"status": "failed", "started_at": iso(-timedelta(hours=24)), "finished_at": prev_end, "message": "boom"}))
client.post("/api/v1/ingest", json=payload({"status": "running", "started_at": iso(-timedelta(minutes=5))}))
job = client.get("/api/v1/jobs").json()["jobs"][0]
assert job["status"] == "running"
assert job["last_run_at"] == to_db(utcnow().fromisoformat(prev_end))
detail = client.get(f"/api/v1/jobs/{job['id']}").json()
assert detail["last_status"] == "failed"
assert detail["message"] == "boom"
def test_next_run_at_only_changes_when_sent(client):
nxt = iso(timedelta(hours=5))
client.post("/api/v1/ingest", json=payload(next_run_at=nxt))
client.post("/api/v1/ingest", json=payload()) # omitted → keep
assert client.get("/api/v1/jobs").json()["jobs"][0]["next_run_at"] == to_db(utcnow().fromisoformat(nxt))
client.post("/api/v1/ingest", json=payload(next_run_at=None)) # explicit null → clear
assert client.get("/api/v1/jobs").json()["jobs"][0]["next_run_at"] is None
def test_history_window_and_run_detail(client):
for days_ago in (40, 20, 1):
start = iso(-timedelta(days=days_ago))
client.post("/api/v1/ingest", json=payload({"status": "success", "started_at": start,
"finished_at": start, "message": f"{days_ago}d"}))
job_id = client.get("/api/v1/jobs").json()["jobs"][0]["id"]
runs = client.get(f"/api/v1/jobs/{job_id}/runs").json()["runs"]
assert len(runs) == 2 # the 40-day-old run is outside the 30-day window
assert "message" not in runs[0]
run = client.get(f"/api/v1/runs/{runs[0]['id']}").json()
assert run["message"] == "1d"
assert client.get(f"/api/v1/jobs/{job_id}/runs?days=31").status_code == 422
@pytest.mark.parametrize("run", [
{"status": "running", "started_at": iso(-timedelta(hours=1)), "finished_at": iso(timedelta(0))},
{"status": "success", "started_at": iso(timedelta(0)), "finished_at": iso(-timedelta(hours=1))},
{"status": "success", "started_at": "2026-01-01T00:00:00"}, # no timezone
{"status": "done", "started_at": iso(timedelta(0))},
])
def test_rejects_bad_runs(client, run):
assert client.post("/api/v1/ingest", json=payload(run)).status_code == 422
def test_tenant_name_is_case_insensitive(client):
client.post("/api/v1/ingest", json=payload())
body = payload()
body["tenant"] = "ACME CORP"
client.post("/api/v1/ingest", json=body)
assert len(client.get("/api/v1/jobs").json()["jobs"]) == 1
def test_me_reports_signed_in_user_from_proxy_headers(client):
assert client.get("/api/v1/me").json() == {"email": None, "name": None}
r = client.get("/api/v1/me", headers={"X-Forwarded-Email": "[email protected]", "X-Forwarded-User": "abc-123"})
assert r.json() == {"email": "[email protected]", "name": "abc-123"}
+52
View File
@@ -0,0 +1,52 @@
import subprocess
import sys
from pathlib import Path
from app.settings import load_settings
BACKEND = Path(__file__).resolve().parents[1]
def run_manage(*args, env, cwd):
return subprocess.run([sys.executable, str(BACKEND / "manage.py"), *args],
capture_output=True, text=True, env=env, cwd=cwd)
def base_env(tmp_path, monkeypatch):
env_file = tmp_path / "backup-dashboard.env"
monkeypatch.setenv("BACKUP_DASH_ENV_FILE", str(env_file))
monkeypatch.delenv("BACKUP_DASH_DB", raising=False)
return env_file, {"BACKUP_DASH_ENV_FILE": str(env_file), "PYTHONPATH": str(BACKEND), "PATH": "/usr/bin:/bin"}
def test_env_file_supplies_db_path_and_real_env_wins(tmp_path, monkeypatch):
env_file, _ = base_env(tmp_path, monkeypatch)
env_file.write_text('# comment\nBACKUP_DASH_DB="/srv/from-file.db"\nBACKUP_DASH_RETENTION_DAYS=120\n')
assert load_settings().db_path == Path("/srv/from-file.db")
assert load_settings().retention_days == 120
monkeypatch.setenv("BACKUP_DASH_DB", "/srv/from-env.db")
assert load_settings().db_path == Path("/srv/from-env.db")
def test_manage_refuses_to_create_db_implicitly(tmp_path, monkeypatch):
_, env = base_env(tmp_path, monkeypatch)
workdir = tmp_path / "somewhere-else"
workdir.mkdir()
r = run_manage("create-key", "k", env=env, cwd=workdir)
assert r.returncode != 0
assert "Database not found" in r.stderr
assert not (workdir / "backup-dashboard.db").exists()
def test_manage_uses_db_from_env_file(tmp_path, monkeypatch):
env_file, env = base_env(tmp_path, monkeypatch)
service_db = tmp_path / "service.db"
env_file.write_text(f"BACKUP_DASH_DB={service_db}\n")
assert run_manage("--create", "list-keys", env=env, cwd=tmp_path).returncode == 0 # service's first start
workdir = tmp_path / "somewhere-else"
workdir.mkdir()
r = run_manage("create-key", "veeam-acme", env=env, cwd=workdir)
assert r.returncode == 0, r.stderr
assert str(service_db) in r.stderr
assert "veeam-acme" in run_manage("list-keys", env=env, cwd=tmp_path).stdout
assert not (workdir / "backup-dashboard.db").exists()
@@ -0,0 +1,270 @@
<#
Shared code for the Backup Dashboard collectors: config and state files, logging, posting to
the ingest API, and installing a collector as a scheduled task.
Each collector imports this from next to itself (installed: C:\Program Files\BackupDashboard)
or from ..\common (in the repo). Collectors on the same server share one installed copy, so
keep these functions backward compatible.
Works on Windows PowerShell 5.1 and PowerShell 7.
#>
$script:Config = $null
$script:DryRun = $false
$script:LogPath = $null
$script:StatePath = $null
$script:LookbackHours = 48
$script:Sent = @{}
$script:RunsSent = 0
$script:MaxLogBytes = 5MB
$script:Invariant = [Globalization.CultureInfo]::InvariantCulture
# ---------- Logging and values ----------
function Write-Log {
param([string]$Message, [string]$Level = 'INFO')
$line = '{0} [{1}] {2}' -f (Get-Date).ToString('yyyy-MM-dd HH:mm:ss'), $Level, $Message
Write-Host $line
if ($script:DryRun -or -not $script:LogPath) { return }
if ((Test-Path $script:LogPath) -and (Get-Item $script:LogPath).Length -gt $script:MaxLogBytes) {
Move-Item $script:LogPath "$($script:LogPath).1" -Force
}
Add-Content -Path $script:LogPath -Value $line -Encoding UTF8
}
function Get-ErrorText($ErrorRecord) {
# Windows PowerShell puts the HTTP response body (e.g. the API's 422 explanation) here
if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) {
return "$($ErrorRecord.Exception.Message) $($ErrorRecord.ErrorDetails.Message)"
}
return $ErrorRecord.Exception.Message
}
# The API needs an explicit UTC offset. DateTimeOffset keeps a Local/Unspecified time as local and a
# Utc time as +00:00, so convert UTC-valued times to Kind Utc before calling this.
function ConvertTo-IsoTime($Value) {
if ($null -eq $Value) { return $null }
$dt = [datetime]$Value
if ($dt.Year -le 1900) { return $null } # "not set" (DateTime.MinValue, MARS's 1601-01-01, ...)
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $script:Invariant)
}
# First property of $Object, out of $Names, that exists and isn't empty. Backup products' object
# models aren't fully documented, so reads go through this rather than assuming one spelling.
function Get-FirstValue($Object, [string[]]$Names) {
if ($null -eq $Object) { return $null }
foreach ($name in $Names) {
try {
$prop = $Object.PSObject.Properties[$name]
if (-not $prop) { continue }
$value = $prop.Value
# Check emptiness by type: casting to [string] would make a one-item list of objects ''
if ($null -eq $value) { continue }
if ($value -is [string] -and $value -eq '') { continue }
if ($value -is [Collections.ICollection] -and $value.Count -eq 0) { continue }
return $value
} catch { }
}
return $null
}
# ---------- Collector run ----------
<#
Loads the config ($ConfigPath, JSON) and the record of runs already sent, and sets up logging
and TLS. Log and state files sit next to the config: <Name>-collector.log / .state.json.
Returns the config object; its LookbackHours is resolved (the parameter, else the config, else
48). Throws if the config can't be read or lacks one of $RequiredKeys.
#>
function Initialize-Collector {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$ConfigPath,
[int]$LookbackHours = 0,
[switch]$DryRun,
[string[]]$RequiredKeys = @('ApiUrl', 'ApiKey', 'Tenant')
)
# Logging first, so a bad config can still be logged
$dataDir = Split-Path -Parent $ConfigPath
$script:LogPath = Join-Path $dataDir "$Name-collector.log"
$script:StatePath = Join-Path $dataDir "$Name-collector.state.json"
$script:DryRun = [bool]$DryRun
$script:RunsSent = 0
$script:Sent = @{}
$config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
foreach ($key in $RequiredKeys) {
if (-not $config.$key) { throw "Config $ConfigPath is missing '$key'" }
}
if ($LookbackHours -le 0) {
$LookbackHours = if ($config.LookbackHours) { [int]$config.LookbackHours } else { 48 }
}
$config | Add-Member -NotePropertyName LookbackHours -NotePropertyValue $LookbackHours -Force
$script:LookbackHours = $LookbackHours
$script:Config = $config
# Windows PowerShell 5.1 may default to TLS 1.0
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
# Run key -> start time (Unix seconds), for runs already delivered in their final state.
# Numbers rather than ISO strings: PowerShell 7's ConvertFrom-Json turns date strings into
# DateTimes and loses the offset.
if (Test-Path $script:StatePath) {
$saved = Get-Content -Path $script:StatePath -Raw | ConvertFrom-Json
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $script:Sent[$p.Name] = [long]$p.Value } }
}
return $config
}
function Test-RunSent([string]$Key) {
return $script:Sent.ContainsKey($Key)
}
<#
Posts one report to the ingest API: the job alone (to refresh next_run_at) or the job with a
run. A run that has finished is then recorded as sent under $SentKey, so later collector runs
skip it; a running one isn't, so it's re-sent until it finishes. Throws on an HTTP error.
#>
function Send-JobReport {
param(
[Parameter(Mandatory)][string]$Tenant,
[Parameter(Mandatory)][string]$SourceSystem,
[Parameter(Mandatory)]$Job,
$Run,
[string]$SentKey,
$StartedAt
)
$body = [ordered]@{ tenant = $Tenant; source_system = $SourceSystem; job = $Job }
if ($Run) { $body.run = $Run }
$json = $body | ConvertTo-Json -Depth 6 -Compress
if ($script:DryRun) {
Write-Host $json
} else {
# Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1,
# mangling non-ASCII job names (e.g. en dashes)
$params = @{
Uri = "$($script:Config.ApiUrl.TrimEnd('/'))/api/v1/ingest"
Method = 'Post'
Headers = @{ Authorization = "Bearer $($script:Config.ApiKey)" }
ContentType = 'application/json; charset=utf-8'
Body = [Text.Encoding]::UTF8.GetBytes($json)
TimeoutSec = 30
}
Invoke-RestMethod @params | Out-Null
}
if ($Run) {
$script:RunsSent++
if ($Run.status -ne 'running' -and $SentKey) {
$script:Sent[$SentKey] = ([DateTimeOffset][datetime]$StartedAt).ToUnixTimeSeconds()
}
}
}
# True when a failed Send-JobReport was the API rejecting the key: no point trying more jobs
function Test-AuthRejected($ErrorRecord) {
$status = 0
try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { }
return $status -eq 401
}
<#
Saves the sent-runs record (dropping runs older than the lookback window plus a day) and logs
the summary line. Doesn't exit: the collector does that with the returned exit code.
#>
function Complete-Collector {
param([int]$JobCount = -1, [int]$Failures = 0)
if (-not $script:DryRun) {
$cutoff = [DateTimeOffset]::Now.AddHours(-$script:LookbackHours - 24).ToUnixTimeSeconds()
$keep = [ordered]@{}
foreach ($key in $script:Sent.Keys) {
if ($script:Sent[$key] -ge $cutoff) { $keep[$key] = $script:Sent[$key] }
}
try {
$keep | ConvertTo-Json -Compress | Set-Content -Path $script:StatePath -Encoding UTF8
} catch {
Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN'
}
}
if ($JobCount -ge 0) {
Write-Log ("Reported {0} jobs, {1} runs, {2} failures" -f $JobCount, $script:RunsSent, $Failures)
} else {
Write-Log ("Reported {0} runs, {1} failures" -f $script:RunsSent, $Failures)
}
if ($Failures) { return 1 }
return 0
}
# ---------- Installing ----------
<#
The part of every collector installer after its own checks: prompt for the API key, write the
config to ProgramData\BackupDashboard (locked to SYSTEM and Administrators, since it holds the
key), copy the collector script and this module to $InstallDir (Program Files: only admins can
modify what runs as SYSTEM), and register a scheduled task running it as SYSTEM.
#>
function Install-CollectorTask {
param(
[Parameter(Mandatory)][string]$Name, # 'veeam' -> veeam-collector.json / .log
[Parameter(Mandatory)][string]$ScriptPath, # the collector script to install
[Parameter(Mandatory)][string]$TaskName,
[Parameter(Mandatory)][string]$TaskDescription,
[Parameter(Mandatory)][string]$Shell, # powershell.exe or pwsh.exe, full path
[Parameter(Mandatory)][string]$ApiUrl,
[Parameter(Mandatory)][int]$IntervalMinutes,
[Parameter(Mandatory)][string]$InstallDir,
[System.Collections.IDictionary]$Settings = @{} # extra config: Tenant, LookbackHours, ...
)
$dataDir = Join-Path $env:ProgramData 'BackupDashboard'
$configPath = Join-Path $dataDir "$Name-collector.json"
if ($ApiUrl -notmatch '^https://') {
Write-Warning 'ApiUrl is not HTTPS: the API key would be sent in clear text.'
}
# Read the key as a SecureString so it isn't echoed to the console or kept in shell history
$secure = Read-Host -AsSecureString 'API key (from: python manage.py create-key <name>)'
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try { $apiKey = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
if (-not $apiKey.StartsWith('bdk_')) { throw "That doesn't look like a dashboard API key (expected 'bdk_...')." }
# Config folder: SYSTEM + Administrators only (SIDs so this works on non-English Windows)
New-Item -ItemType Directory -Path $dataDir -Force | Out-Null
& icacls.exe $dataDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null
if ($LASTEXITCODE) { throw "icacls failed on $dataDir" }
$config = [ordered]@{ ApiUrl = $ApiUrl.TrimEnd('/'); ApiKey = $apiKey }
foreach ($key in $Settings.Keys) { $config[$key] = $Settings[$key] }
$config | ConvertTo-Json -Depth 4 | Set-Content -Path $configPath -Encoding UTF8
# The collector and this module, side by side (the collector looks for the module next to itself)
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
$installedScript = Join-Path $InstallDir (Split-Path -Leaf $ScriptPath)
foreach ($file in $ScriptPath, $PSCommandPath) {
$target = Join-Path $InstallDir (Split-Path -Leaf $file)
Copy-Item -Path $file -Destination $target -Force
Unblock-File -Path $target
}
$action = New-ScheduledTaskAction -Execute $Shell `
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$installedScript`""
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes)
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
$taskSettings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
-MultipleInstances IgnoreNew -StartWhenAvailable
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal `
-Settings $taskSettings -Description $TaskDescription -Force | Out-Null
Write-Host "Installed. Config: $configPath"
Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM, using $Shell."
Write-Host ''
Write-Host 'Next steps:'
Write-Host " 1. Preview what will be sent: & '$Shell' -File '$installedScript' -DryRun"
Write-Host " 2. Backfill 30 days of history: & '$Shell' -File '$installedScript' -LookbackHours 720"
Write-Host " 3. Check the log: Get-Content '$dataDir\$Name-collector.log' -Tail 20"
}
Export-ModuleMember -Function Write-Log, Get-ErrorText, ConvertTo-IsoTime, Get-FirstValue,
Initialize-Collector, Test-RunSent, Send-JobReport, Test-AuthRejected, Complete-Collector,
Install-CollectorTask
@@ -0,0 +1,102 @@
<#
.SYNOPSIS
Read-only dump of what the MARS agent's cmdlets return, for troubleshooting the collector.
.DESCRIPTION
The collector reads properties of Get-OBJob / Get-OBPolicy / Get-OBSchedule results whose names
aren't documented. This prints those objects' real structure (property names, types and values,
a few levels deep) so mismatches can be fixed. Nothing is changed. Output is also saved to a
text file.
.EXAMPLE
.\Get-MarsCollectorDiagnostics.ps1
#>
[CmdletBinding()]
param(
[int]$Jobs = 3,
[string]$OutFile = (Join-Path $env:TEMP 'mars-collector-diagnostics.txt')
)
$ErrorActionPreference = 'Stop'
$MaxDepth = 6
if (-not (Get-Command Get-OBJob -ErrorAction SilentlyContinue)) {
if (Get-Module -ListAvailable -Name MSOnlineBackup) {
Import-Module MSOnlineBackup
} else {
Import-Module (Join-Path $env:ProgramFiles 'Microsoft Azure Recovery Services Agent\bin\Modules\MSOnlineBackup')
}
}
function Test-Scalar($Value) {
return $null -eq $Value -or $Value -is [string] -or $Value -is [ValueType] -or $Value -is [guid]
}
# Recursive "name (type) = value" listing. Collections show their first 3 items.
function Show-Object($Value, [string]$Indent = ' ', [int]$Depth = 0) {
if (Test-Scalar $Value) { return }
if ($Depth -ge $MaxDepth) { "$Indent..."; return }
if ($Value -is [Collections.IEnumerable]) {
$items = @($Value)
"$Indent[$($items.Count) item(s)]"
$i = 0
foreach ($item in $items | Select-Object -First 3) {
"$Indent[$i] ($($item.GetType().FullName))$(if (Test-Scalar $item) { " = $item" })"
Show-Object $item "$Indent " ($Depth + 1)
$i++
}
return
}
foreach ($p in $Value.PSObject.Properties) {
try { $v = $p.Value } catch { "$Indent$($p.Name) = <error: $($_.Exception.Message)>"; continue }
$type = if ($null -eq $v) { 'null' } else { $v.GetType().FullName }
if (Test-Scalar $v) {
"$Indent$($p.Name) ($type) = $v"
} else {
$text = [string]$v
if ($text.Length -gt 100) { $text = $text.Substring(0, 97) + '...' }
"$Indent$($p.Name) ($type) = $text"
Show-Object $v "$Indent " ($Depth + 1)
}
}
}
function Show-Section([string]$Title, [scriptblock]$Get) {
"== $Title"
try {
$result = @(& $Get)
if ($result.Count -eq 0 -or ($result.Count -eq 1 -and $null -eq $result[0])) { ' (nothing returned)' }
$n = 0
foreach ($item in $result) {
if ($null -eq $item) { continue }
" [$n] ($($item.GetType().FullName))"
Show-Object $item ' '
$n++
}
} catch {
" error: $($_.Exception.Message)"
}
''
}
$report = & {
$agent = try {
(Get-Item (Join-Path $env:ProgramFiles 'Microsoft Azure Recovery Services Agent\bin\cbengine.exe')).VersionInfo.ProductVersion
} catch { 'unknown' }
"Generated $(Get-Date -Format o) on $env:COMPUTERNAME, MARS agent $agent, PowerShell $($PSVersionTable.PSVersion)"
''
Show-Section 'Get-OBJob (in progress)' { Get-OBJob }
Show-Section "Get-OBJob -Previous $Jobs" { Get-OBJob -Previous $Jobs }
Show-Section 'Get-OBPolicy' { Get-OBPolicy }
Show-Section 'Get-OBPolicy | Get-OBSchedule' { Get-OBSchedule -Policy (Get-OBPolicy) }
if (Get-Command Get-OBSystemStatePolicy -ErrorAction SilentlyContinue) {
Show-Section 'Get-OBSystemStatePolicy | Get-OBSchedule' {
$ssPolicy = Get-OBSystemStatePolicy
if ($ssPolicy) { Get-OBSchedule -Policy $ssPolicy } else { '(no System State policy configured)' }
}
}
} | Out-String -Width 300
$report
$report | Set-Content -Path $OutFile -Encoding UTF8
Write-Host "Saved to $OutFile"
+55
View File
@@ -0,0 +1,55 @@
<#
.SYNOPSIS
Installs the MARS (Azure Backup agent) collector as a SYSTEM scheduled task.
.DESCRIPTION
- Copies Send-MarsBackupStatus.ps1 to Program Files (only admins can modify it, which
matters because it runs as SYSTEM).
- Writes the config to ProgramData\BackupDashboard and restricts that folder to SYSTEM and
Administrators, since it contains the API key.
- Registers a scheduled task that runs every IntervalMinutes under Windows PowerShell 5.1
(powershell.exe), which the agent's MSOnlineBackup module targets.
Safe to re-run to change settings or update the script. Can sit alongside the Veeam collector.
.EXAMPLE
.\Install-MarsCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
(prompts for the API key)
#>
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ApiUrl,
[Parameter(Mandatory)][string]$Tenant,
# Name shown on the dashboard; defaults to "<server> - Azure Backup"
[string]$JobName,
[string]$SourceSystem = 'Azure Backup (MARS)',
[ValidateRange(5, 1440)][int]$IntervalMinutes = 60,
[ValidateRange(1, 720)][int]$LookbackHours = 48,
[string]$InstallDir = (Join-Path $env:ProgramFiles 'BackupDashboard')
)
$ErrorActionPreference = 'Stop'
$WindowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
# Check the agent is here before changing anything
$agentModule = Join-Path $env:ProgramFiles 'Microsoft Azure Recovery Services Agent\bin\Modules\MSOnlineBackup'
if (-not (Get-Module -ListAvailable -Name MSOnlineBackup) -and -not (Test-Path $agentModule)) {
throw 'The MARS agent (MSOnlineBackup PowerShell module) is not installed on this server.'
}
# Shared installer code: in the repo it's in ..\common, so copy the whole collectors folder
$sharedModule = @(
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $sharedModule) {
throw "BackupDashboard.Collector.psm1 not found. Copy the whole collectors folder, including common."
}
Import-Module $sharedModule -Force
$settings = [ordered]@{ Tenant = $Tenant; SourceSystem = $SourceSystem; LookbackHours = $LookbackHours }
if ($JobName) { $settings.JobName = $JobName }
Install-CollectorTask -Name 'mars' -ScriptPath (Join-Path $PSScriptRoot 'Send-MarsBackupStatus.ps1') `
-TaskName 'Backup Dashboard - MARS collector' `
-TaskDescription 'Sends Azure Backup (MARS) results to the Backup Dashboard' `
-Shell $WindowsPowerShell -ApiUrl $ApiUrl -IntervalMinutes $IntervalMinutes -InstallDir $InstallDir -Settings $settings
+68
View File
@@ -0,0 +1,68 @@
# Azure Backup (MARS agent) collector
A PowerShell script that runs on each server with the Microsoft Azure Recovery Services (MARS)
agent and reports its backups to the dashboard. Each server appears as one job,
named `<SERVER> - Azure Backup` by default.
> **Checked against:** MARS agent 2.0.9274, a Files & Folders policy with completed backups only.
> Not yet seen on a real agent: a failed backup, a backup in progress, or a System State policy.
> Those paths are written to the same object model, but if one looks wrong, run
> `Get-MarsCollectorDiagnostics.ps1` while it's in that state and send the output.
## Install (on the server, as an administrator)
Runs under Windows PowerShell 5.1 (`powershell.exe`), which the agent's `MSOnlineBackup` module
targets and which every supported Windows Server has.
1. On the dashboard host, create a key for this server:
```bash
python manage.py create-key mars-acme-fs01
```
2. Copy the whole `collectors` folder to the server (the installer needs `common`
as well as this folder), then from `collectors\mars` run:
```powershell
.\Install-MarsCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
```
Add `-JobName "File Server"` to choose the name shown on the dashboard. It prompts for the
key, then:
- writes `C:\ProgramData\BackupDashboard\mars-collector.json` (readable only by SYSTEM and Administrators)
- copies the script to `C:\Program Files\BackupDashboard\`
- registers the scheduled task **Backup Dashboard - MARS collector** (SYSTEM, every 60 minutes)
3. Preview the payloads, then backfill 30 days so the history view is populated straight away:
```powershell
powershell.exe -File 'C:\Program Files\BackupDashboard\Send-MarsBackupStatus.ps1' -DryRun
powershell.exe -File 'C:\Program Files\BackupDashboard\Send-MarsBackupStatus.ps1' -LookbackHours 720
```
It can be installed alongside the other collectors: it uses its own config, state, log and task.
Re-run the installer to change settings or update the script. It also installs
`BackupDashboard.Collector.psm1`, the code shared by all collectors, next to the script.
## What it sends
| Dashboard | MARS |
|---|---|
| Company | `Tenant` from the config |
| Job | One per server, ID `mars:<computer name>`, so changing `JobName` renames rather than duplicates it |
| Status | Backup job state: Failed → **Failed**. Completed with failed bytes or an error → **Warning**. Aborted/cancelled or unrecognised → **Warning**. In progress → **Running**. Restore jobs are ignored |
| Started / finished | Job start / end time. MARS reports these in UTC without marking them as UTC; the collector handles that |
| Next scheduled | Next slot from the backup policy schedule, or the System State policy's if that's sooner. Empty (so never Overdue) for schedules that run every 2+ weeks, since which week is next can't be told |
| Message | Job and per-datasource errors (non-zero error codes), failed file counts, and the failed-file log path |
| Size | Bytes transferred, summed across datasources |
Each run covers the last 48 hours (`LookbackHours`). Jobs already delivered are recorded in
`mars-collector.state.json`.
## Troubleshooting
- Log: `C:\ProgramData\BackupDashboard\mars-collector.log`. Exit codes: `0` OK, `1` some runs
failed to send (retried next run), `2` setup failed (config or MARS module).
- Wrong status, sizes or messages: run `.\Get-MarsCollectorDiagnostics.ps1` (read-only). It prints
the real structure of the objects the agent's cmdlets return.
## Testing without MARS
```powershell
. ./tests/MockMars.ps1
./Send-MarsBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
```
+320
View File
@@ -0,0 +1,320 @@
<#
.SYNOPSIS
Reports Microsoft Azure Recovery Services (MARS) agent backups to the Backup Dashboard.
.DESCRIPTION
Runs on a server with the MARS agent, normally as a SYSTEM scheduled task every 60 minutes
(see Install-MarsCollector.ps1). The server appears on the dashboard as one job. Each run sends:
- the next scheduled backup, worked out from the backup (and System State) policy schedules,
which drives the dashboard's Overdue flag, and
- every backup job from the last LookbackHours that the dashboard hasn't already received in
its final state. A backup in progress is sent as Running until it finishes.
Already-reported jobs are tracked in a state file, and the ingest API is idempotent, so a
failed or repeated run is harmless: anything not confirmed is retried next time.
Written for Windows PowerShell 5.1, which the MARS agent's MSOnlineBackup module targets.
.PARAMETER ConfigPath
JSON config with ApiUrl, ApiKey and Tenant (optional: JobName, SourceSystem, LookbackHours).
.PARAMETER LookbackHours
Overrides the config value. Use 720 once to backfill 30 days of history.
.PARAMETER DryRun
Print the payloads instead of sending them. Doesn't touch the state or log files.
.EXAMPLE
.\Send-MarsBackupStatus.ps1 -DryRun
.EXAMPLE
.\Send-MarsBackupStatus.ps1 -LookbackHours 720
#>
[CmdletBinding()]
param(
[string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\mars-collector.json'),
[int]$LookbackHours = 0,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
# Shared collector code: installed next to this script; in the repo, in ..\common
$sharedModule = @(
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $sharedModule) {
Write-Host "BackupDashboard.Collector.psm1 not found next to $PSCommandPath; re-run the installer."
exit 2
}
Import-Module $sharedModule -Force
# MARS keeps a limited job history; this comfortably covers 30 days at 3 backups a day
$MaxPreviousJobs = 200
# ---------- Helpers ----------
function Import-Mars {
if (Get-Command Get-OBJob -ErrorAction SilentlyContinue) { return } # already loaded
if (Get-Module -ListAvailable -Name MSOnlineBackup) {
Import-Module MSOnlineBackup
return
}
# The agent normally adds its module folder to PSModulePath; fall back to the default install path
$agentModule = Join-Path $env:ProgramFiles 'Microsoft Azure Recovery Services Agent\bin\Modules\MSOnlineBackup'
Import-Module $agentModule
}
# ---------- Schedule ----------
# Next slot strictly after $After for a MARS schedule (days of week + times of day).
# Returns $null when there's no schedule, or when it runs every N>1 weeks: we can't tell which
# week is next, and a wrong guess would raise a false Overdue alarm.
function Get-NextSlot($Schedule, [datetime]$After) {
if ($null -eq $Schedule) { return $null }
$weeks = Get-FirstValue $Schedule @('ScheduleWeeklyFrequency', 'WeeksInterval')
if ($weeks -and [int]$weeks -gt 1) { return $null }
$days = @(Get-FirstValue $Schedule @('ScheduleRunDays', 'DaysOfWeek') | ForEach-Object { [string]$_ })
# TimeSpans (time of day, local) on real agents; accept DateTimes too
$times = @(Get-FirstValue $Schedule @('ScheduleRunTimes', 'TimesOfDay') | ForEach-Object {
if ($_ -is [timespan]) { $_ } else { ([datetime]$_).TimeOfDay }
} | Sort-Object)
if ($days.Count -eq 0 -or $times.Count -eq 0) { return $null }
for ($offset = 0; $offset -le 7; $offset++) {
$date = $After.Date.AddDays($offset)
if ($days -notcontains [string]$date.DayOfWeek) { continue }
foreach ($time in $times) {
$candidate = $date + $time
if ($candidate -gt $After) { return $candidate }
}
}
return $null
}
function Get-NextRun {
$next = $null
$policies = @()
try { $policies += @(Get-OBPolicy) } catch { Write-Log "Couldn't read the backup policy: $(Get-ErrorText $_)" 'WARN' }
# Windows Server can have a separate System State policy with its own schedule
if (Get-Command Get-OBSystemStatePolicy -ErrorAction SilentlyContinue) {
try { $policies += @(Get-OBSystemStatePolicy) } catch { }
}
foreach ($policy in $policies) {
if ($null -eq $policy) { continue }
try {
$slot = Get-NextSlot (Get-OBSchedule -Policy $policy) (Get-Date)
if ($slot -and (-not $next -or $slot -lt $next)) { $next = $slot }
} catch {
Write-Log "Couldn't read a policy schedule: $(Get-ErrorText $_)" 'WARN'
}
}
return ConvertTo-IsoTime $next
}
# ---------- Jobs ----------
# A job's StartTime/EndTime are UTC values, though not necessarily marked as UTC: on a real agent
# StartTime matched the "StartTimeUTC" in the status text, while DisplayTime held local time.
# Returns $null for "not set" (1601-01-01), e.g. EndTime while a backup is running.
function Get-JobTimeUtc($Job, [string]$Field) {
$value = Get-FirstValue $Job.JobStatus @($Field)
if ($null -eq $value) { return $null }
$dt = [datetime]$value
if ($dt.Year -le 1900) { return $null }
return [datetime]::SpecifyKind($dt, [DateTimeKind]::Utc)
}
# MARS always includes an ErrorInfo; ErrorCode 0 means no error
function Test-HasError($ErrorInfo) {
if (-not $ErrorInfo) { return $false }
$code = Get-FirstValue $ErrorInfo @('ErrorCode')
$detailed = Get-FirstValue $ErrorInfo @('DetailedErrorCode')
return ($code -and [long]$code -ne 0) -or ($detailed -and [long]$detailed -ne 0)
}
# DatasourceStatus.Datasource is an object whose DataSourceName is e.g. "E:\" or "System State"
function Get-DatasourceName($DatasourceStatus) {
$ds = Get-FirstValue $DatasourceStatus @('Datasource', 'DataSource')
$name = Get-FirstValue $ds @('DataSourceName', 'FriendlyNameDSPS', 'Name')
if ($name) { return [string]$name }
if ($ds -is [string]) { return $ds }
return 'Datasource'
}
function Get-FailedFiles($Job) {
$sum = [long]0
foreach ($ds in Get-DatasourceStatuses $Job) {
$value = Get-FirstValue (Get-FirstValue $ds @('FileProgress')) @('Failed')
if ($value) { $sum += [long]$value }
}
return $sum
}
function Get-JobStatus($Job) {
$status = $Job.JobStatus
$state = [string](Get-FirstValue $status @('JobState', 'State'))
if (-not (Get-JobTimeUtc $Job 'EndTime') -and $state -notmatch 'Fail|Abort|Cancel|Complete|Success|Succeed|Warn') {
return 'running'
}
if ($state -match 'Fail') { return 'failed' }
if ($state -match 'Warn|Abort|Cancel|Stop') { return 'warning' }
if ($state -match 'Complete|Success|Succeed') {
# MARS can report Completed while individual files failed or an error was recorded
if ((Get-FailedBytes $Job) -gt 0 -or (Get-FailedFiles $Job) -gt 0 -or
(Test-HasError (Get-FirstValue $status @('ErrorInfo')))) { return 'warning' }
return 'success'
}
return 'warning' # finished in a state we don't recognise: surface it rather than hide it
}
function Get-DatasourceStatuses($Job) {
return @(Get-FirstValue $Job.JobStatus @('DatasourceStatus', 'DataSourceStatus') | Where-Object { $_ })
}
function Get-ByteTotal($Job, [string]$Field) {
$sum = [long]0
foreach ($ds in Get-DatasourceStatuses $Job) {
$progress = Get-FirstValue $ds @('ByteProgress')
$value = Get-FirstValue $progress @($Field)
if ($value) { $sum += [long]$value }
}
return $sum
}
function Get-FailedBytes($Job) { return Get-ByteTotal $Job 'Failed' }
function Format-ErrorInfo($ErrorInfo) {
if (-not (Test-HasError $ErrorInfo)) { return $null }
$text = Get-FirstValue $ErrorInfo @('ErrorMessage', 'Message', 'Description')
if ($text) { return ([string]$text).Trim() }
$code = Get-FirstValue $ErrorInfo @('ErrorCode', 'DetailedErrorCode')
$params = @(Get-FirstValue $ErrorInfo @('ErrorParamList') | Where-Object { $_ } | ForEach-Object {
$name = Get-FirstValue $_ @('Name')
$value = Get-FirstValue $_ @('Value')
if ($name) { "$name=$value" } else { [string]$_ }
} | Where-Object { $_ })
$line = if ($code) { "Error code $code" } else { [string]$ErrorInfo }
if ($params.Count) { $line += " ($($params -join ', '))" }
return $line
}
function Get-RunMessage($Job, [string]$Status) {
$lines = New-Object 'System.Collections.Generic.List[string]'
$state = [string](Get-FirstValue $Job.JobStatus @('JobState', 'State'))
$jobError = Format-ErrorInfo (Get-FirstValue $Job.JobStatus @('ErrorInfo'))
if ($jobError) { $lines.Add($jobError) }
foreach ($ds in Get-DatasourceStatuses $Job) {
$name = Get-DatasourceName $ds
$dsError = Format-ErrorInfo (Get-FirstValue $ds @('ErrorInfo'))
$failedFiles = Get-FirstValue (Get-FirstValue $ds @('FileProgress')) @('Failed')
$failedBytes = Get-FirstValue (Get-FirstValue $ds @('ByteProgress')) @('Failed')
if ($dsError) { $lines.Add("${name}: $dsError") }
if ($failedFiles -and [long]$failedFiles -gt 0) { $lines.Add("${name}: $failedFiles file(s) failed to back up") }
elseif ($failedBytes -and [long]$failedBytes -gt 0) { $lines.Add("${name}: $failedBytes bytes failed to back up") }
}
$failedLog = Get-FirstValue $Job.JobStatus @('FailedFileLog')
if ($failedLog -and $lines.Count) { $lines.Add("Failed file log: $failedLog") }
if ($lines.Count -eq 0) {
switch ($Status) {
'success' { return 'Backup completed successfully.' }
'running' { return '' }
default { return "Backup finished with state '$state'." }
}
}
if ($state -and $Status -ne 'success') { $lines.Insert(0, "Job state: $state") }
return ($lines -join "`n")
}
function ConvertTo-Run($Job) {
$status = Get-JobStatus $Job
$datasources = @(Get-DatasourceStatuses $Job | ForEach-Object { Get-DatasourceName $_ })
$details = [ordered]@{ server = $env:COMPUTERNAME }
if ($datasources.Count) { $details.datasources = $datasources }
$agentState = Get-FirstValue $Job.JobStatus @('JobState', 'State')
if ($agentState) { $details.mars_state = [string]$agentState }
$run = [ordered]@{
external_id = [string]$Job.JobId
status = $status
started_at = ConvertTo-IsoTime (Get-JobTimeUtc $Job 'StartTime')
message = Get-RunMessage $Job $status
details = $details
}
if ($status -ne 'running') {
$finished = ConvertTo-IsoTime (Get-JobTimeUtc $Job 'EndTime')
if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time
$transferred = Get-ByteTotal $Job 'Progress'
if ($transferred -gt 0) { $run.size_bytes = $transferred }
$failedBytes = Get-FailedBytes $Job
if ($failedBytes -gt 0) { $details.failed_bytes = $failedBytes }
$failedFiles = Get-FailedFiles $Job
if ($failedFiles -gt 0) { $details.failed_files = $failedFiles }
}
return $run
}
function Test-IsBackupJob($Job) {
# Get-OBJob also lists restores (JobType Recovery); only backups belong on the dashboard
$type = [string](Get-FirstValue $Job @('JobType', 'Type'))
return (-not $type) -or $type -match 'Backup'
}
# ---------- Main ----------
try {
$Config = Initialize-Collector -Name 'mars' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Azure Backup (MARS)' }
$JobName = if ($Config.JobName) { [string]$Config.JobName } else { "$env:COMPUTERNAME - Azure Backup" }
$LookbackHours = $Config.LookbackHours
Import-Mars
$sinceUtc = (Get-Date).ToUniversalTime().AddHours(-$LookbackHours)
# Get-OBJob with no arguments lists jobs in progress; -Previous lists finished ones
$jobs = @(@(Get-OBJob) + @(Get-OBJob -Previous $MaxPreviousJobs) | Where-Object {
$_ -and (Test-IsBackupJob $_)
})
$byId = [ordered]@{}
foreach ($job in $jobs) {
$start = Get-JobTimeUtc $job 'StartTime'
if (-not $start -or $start -lt $sinceUtc) { continue }
$byId[[string]$job.JobId] = $job # a job in both lists: the finished entry (later) wins
}
$pending = @($byId.Values |
Where-Object { -not (Test-RunSent ([string]$_.JobId)) } |
Sort-Object { Get-JobTimeUtc $_ 'StartTime' })
$jobInfo = [ordered]@{
# Stable per server, so renaming the dashboard job via JobName doesn't create a new one
external_id = "mars:$($env:COMPUTERNAME.ToLowerInvariant())"
name = $JobName
# Always sent (null when unscheduled) so the dashboard's value tracks the agent's
next_run_at = Get-NextRun
}
} catch {
Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR'
exit 2
}
$failures = 0
try {
if ($pending.Count -eq 0) {
# Keep next_run_at fresh even when nothing new ran
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo
}
foreach ($job in $pending) {
$run = ConvertTo-Run $job
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.external_id -StartedAt ((Get-JobTimeUtc $job 'StartTime'))
}
} catch {
$failures++
Write-Log "Failed to report: $(Get-ErrorText $_)" 'ERROR'
}
$exitCode = Complete-Collector -Failures $failures
if ($exitCode) { exit $exitCode }
+78
View File
@@ -0,0 +1,78 @@
# Stand-ins for the MARS agent cmdlets. Object shapes follow Get-MarsCollectorDiagnostics.ps1
# output from a real agent (2.0.9274): job times are UTC values with no UTC marking, "not set" is
# 1601-01-01, ErrorInfo is always present (ErrorCode 0 = no error), Datasource is an object, and
# schedule times are TimeSpans.
#
# . ./tests/MockMars.ps1
# ./Send-MarsBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
# Fixed to the top of the hour, and IDs derived from times, so repeated runs see the same jobs
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
$notSet = [datetime]::new(1601, 1, 1)
function New-StableGuid([string]$Seed) {
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
}
# Local time -> the UTC value MARS reports, with Kind Unspecified like the real objects
function ConvertTo-MarsTime([datetime]$Local) {
[datetime]::SpecifyKind($Local.ToUniversalTime(), [DateTimeKind]::Unspecified)
}
function New-MockErrorInfo([int]$Code = 0, $Params = @()) {
[pscustomobject]@{ DetailedErrorCode = 0; DetailedErrorSource = $null; ErrorCode = $Code; ErrorParamList = $Params }
}
function New-MockDatasource($Name, [uint64]$Done, [uint64]$FailedBytes = 0, [uint64]$FailedFiles = 0, $ErrorInfo = (New-MockErrorInfo)) {
[pscustomobject]@{
ByteProgress = [pscustomobject]@{ Changed = $Done; Failed = $FailedBytes; Progress = $Done; Total = 2TB }
Datasource = [pscustomobject]@{ DataSourceName = $Name; DataSourceId = 6310158806455554328 }
ErrorInfo = $ErrorInfo
FileProgress = [pscustomobject]@{ Changed = 100; CurrentFile = ''; Failed = $FailedFiles; Progress = 100; Total = 100 }
JobState = 'Completed'
}
}
function New-MockJob($Type, [datetime]$Start, $End, $State, $Datasources = @(), $ErrorInfo = (New-MockErrorInfo), $FailedFileLog = '') {
$endValue = if ($End) { ConvertTo-MarsTime $End } else { $notSet }
[pscustomobject]@{
IsRetried = $false
JobId = New-StableGuid "$Type|$($Start.Ticks)"
JobType = $Type
DisplayTime = $Start # the only local time on the object
JobStatus = [pscustomobject]@{
DatasourceStatus = $Datasources
EndTime = $endValue
ErrorInfo = $ErrorInfo
FailedFileLog = $FailedFileLog
JobState = $State
StartTime = ConvertTo-MarsTime $Start
}
}
}
$global:MockMarsRunning = @(
(New-MockJob 'Backup' $now.AddMinutes(-10) $null 'InProgress' @((New-MockDatasource 'E:\' 2GB)))
)
$global:MockMarsPrevious = @(
(New-MockJob 'Backup' $now.AddHours(-12) $now.AddHours(-11.5) 'Completed' @((New-MockDatasource 'E:\' 523407872))),
# Completed, but some files failed
(New-MockJob 'Backup' $now.AddHours(-36) $now.AddHours(-35) 'Completed' @(
(New-MockDatasource 'E:\' 3GB 40MB 12)) -FailedFileLog 'C:\Program Files\Microsoft Azure Recovery Services Agent\Temp\Errors.log'),
(New-MockJob 'Backup' $now.AddHours(-30) $now.AddHours(-29.5) 'Failed' @() `
(New-MockErrorInfo 100050 @([pscustomobject]@{ Name = 'Volume'; Value = 'E:' }))),
(New-MockJob 'Recovery' $now.AddHours(-5) $now.AddHours(-4) 'Completed'), # a restore: must be ignored
(New-MockJob 'Backup' $now.AddDays(-5) $now.AddDays(-5).AddHours(1) 'Completed') # outside 48h lookback
)
# Every day at 21:00 local, as on the real server
$global:MockMarsSchedule = [pscustomobject]@{
ScheduleRunDays = [DayOfWeek[]](0..6)
ScheduleRunTimes = [timespan[]]@([timespan]::FromHours(21))
ScheduleWeeklyFrequency = 1
TypeOfBackupSchedule = 'Daily'
}
function Get-OBJob { param([int]$Previous) if ($PSBoundParameters.ContainsKey('Previous')) { $global:MockMarsPrevious } else { $global:MockMarsRunning } }
function Get-OBPolicy { [pscustomobject]@{ PolicyName = 'policy'; BackupSchedule = $global:MockMarsSchedule } }
function Get-OBSchedule { param($Policy) $global:MockMarsSchedule }
function Get-OBSystemStatePolicy { $null } # none configured, as on the real server
@@ -0,0 +1,119 @@
<#
.SYNOPSIS
Read-only dump of what the Veeam Backup for Microsoft 365 cmdlets return, for troubleshooting
the collector.
.DESCRIPTION
The collector reads properties of Get-VBOJob / Get-VBOJobSession results whose names aren't
fully documented. This prints those objects' real structure (property names, types, values and
each date's Kind, a few levels deep) for a few jobs and their latest sessions. Nothing is
changed. Output is also saved to a text file.
.EXAMPLE
.\Get-Vb365CollectorDiagnostics.ps1
#>
[CmdletBinding()]
param(
[int]$Jobs = 3,
[int]$SessionsPerJob = 2,
[string]$OutFile = (Join-Path $env:TEMP 'vb365-collector-diagnostics.txt')
)
$ErrorActionPreference = 'Stop'
$MaxDepth = 4
if (-not (Get-Command Get-VBOJob -ErrorAction SilentlyContinue)) {
if (Get-Module -ListAvailable -Name Veeam.Archiver.PowerShell) {
Import-Module Veeam.Archiver.PowerShell
} else {
Import-Module (Join-Path $env:ProgramFiles 'Veeam\Backup365\Veeam.Archiver.PowerShell\Veeam.Archiver.PowerShell.psd1')
}
}
function Test-Scalar($Value) {
return $null -eq $Value -or $Value -is [string] -or $Value -is [ValueType] -or $Value -is [guid]
}
# Recursive "name (type) = value" listing. Collections show their first 3 items.
function Show-Object($Value, [string]$Indent = ' ', [int]$Depth = 0) {
if (Test-Scalar $Value) { return }
if ($Depth -ge $MaxDepth) { "$Indent..."; return }
if ($Value -is [Collections.IEnumerable]) {
$items = @($Value)
"$Indent[$($items.Count) item(s)]"
$i = 0
foreach ($item in $items | Select-Object -First 3) {
"$Indent[$i] ($($item.GetType().FullName))$(if (Test-Scalar $item) { " = $item" })"
Show-Object $item "$Indent " ($Depth + 1)
$i++
}
return
}
foreach ($p in $Value.PSObject.Properties) {
try { $v = $p.Value } catch { "$Indent$($p.Name) = <error: $($_.Exception.Message)>"; continue }
$type = if ($null -eq $v) { 'null' } else { $v.GetType().FullName }
if (Test-Scalar $v) {
$kind = if ($v -is [datetime]) { " [Kind=$($v.Kind)]" } else { '' }
"$Indent$($p.Name) ($type) = $v$kind"
} else {
$text = [string]$v
if ($text.Length -gt 100) { $text = $text.Substring(0, 97) + '...' }
"$Indent$($p.Name) ($type) = $text"
Show-Object $v "$Indent " ($Depth + 1)
}
}
}
function Show-Section([string]$Title, [scriptblock]$Get) {
"== $Title"
try {
$result = @(& $Get)
if ($result.Count -eq 0 -or ($result.Count -eq 1 -and $null -eq $result[0])) { ' (nothing returned)' }
$n = 0
foreach ($item in $result) {
if ($null -eq $item) { continue }
" [$n] ($($item.GetType().FullName))"
Show-Object $item ' '
$n++
}
} catch {
" error: $($_.Exception.Message)"
}
''
}
$report = & {
$module = Get-Module Veeam.Archiver.PowerShell
"Generated $(Get-Date -Format o) on $env:COMPUTERNAME, PowerShell $($PSVersionTable.PSVersion), module $($module.Version)"
"Local time zone: $([TimeZoneInfo]::Local.Id), UTC offset now $([TimeZoneInfo]::Local.GetUtcOffset((Get-Date))), culture $([Globalization.CultureInfo]::CurrentCulture.Name)"
''
"== Organizations"
try {
Get-VBOOrganization | ForEach-Object { " $($_.Name) [$($_.Type)]" }
} catch { " error: $($_.Exception.Message)" }
''
$allJobs = @(Get-VBOJob)
"== Jobs: $($allJobs.Count) in total"
$allJobs | ForEach-Object { " $($_.Name) [$($_.Id)] enabled=$($_.IsEnabled) next=$($_.NextRun)" }
''
Show-Section "Get-VBOJob (first $Jobs, full detail)" { $allJobs | Select-Object -First $Jobs }
foreach ($j in $allJobs | Select-Object -First $Jobs) {
Show-Section "Get-VBOJobSession -Job '$($j.Name)' (latest $SessionsPerJob)" {
Get-VBOJobSession -Job $j | Sort-Object CreationTime -Descending | Select-Object -First $SessionsPerJob
}
}
if (Get-Command Get-VBOCopyJob -ErrorAction SilentlyContinue) {
Show-Section 'Get-VBOCopyJob (first 2)' { Get-VBOCopyJob | Select-Object -First 2 }
if (Get-Command Get-VBOCopyJobSession -ErrorAction SilentlyContinue) {
Show-Section 'Get-VBOCopyJobSession (latest 2)' {
Get-VBOCopyJobSession | Sort-Object CreationTime -Descending | Select-Object -First 2
}
}
} else {
'== Get-VBOCopyJob: not available'
}
} | Out-String -Width 300
$report
$report | Set-Content -Path $OutFile -Encoding UTF8
Write-Host "Saved to $OutFile"
@@ -0,0 +1,74 @@
<#
.SYNOPSIS
Installs the Veeam Backup for Microsoft 365 collector as a SYSTEM scheduled task.
.DESCRIPTION
- Copies Send-Vb365BackupStatus.ps1 to Program Files (only admins can modify it, which
matters because it runs as SYSTEM).
- Writes the config to ProgramData\BackupDashboard and restricts that folder to SYSTEM and
Administrators, since it contains the API key.
- Registers a scheduled task that runs every IntervalMinutes, under Windows PowerShell 5.1
(powershell.exe) unless -PwshPath points at PowerShell 7.
Safe to re-run to change settings or update the script. Can sit alongside the other collectors.
Dashboard company per job: -OrganizationTenants maps Microsoft 365 organization names to
companies; jobs of unmapped organizations use -Tenant, or the organization's name if no -Tenant.
.EXAMPLE
.\Install-Vb365Collector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
One client per VB365 server. Prompts for the API key.
.EXAMPLE
.\Install-Vb365Collector.ps1 -ApiUrl https://backups.example.com -OrganizationTenants @{
'acme.onmicrosoft.com' = 'Acme Corp'; 'bluewater.onmicrosoft.com' = 'Bluewater Dental' }
A shared VB365 server protecting several organizations.
#>
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ApiUrl,
[string]$Tenant,
[hashtable]$OrganizationTenants = @{},
[string]$SourceSystem = 'Veeam Backup for M365',
[ValidateRange(5, 1440)][int]$IntervalMinutes = 60,
[ValidateRange(1, 720)][int]$LookbackHours = 48,
[string]$InstallDir = (Join-Path $env:ProgramFiles 'BackupDashboard'),
# Run under PowerShell 7 instead of Windows PowerShell, e.g. 'C:\Program Files\PowerShell\7\pwsh.exe'
[string]$PwshPath
)
$ErrorActionPreference = 'Stop'
$Shell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
if ($PwshPath) {
if (-not (Test-Path $PwshPath)) { throw "PowerShell 7 not found at '$PwshPath'." }
$Shell = $PwshPath
}
# Check VB365 is here before changing anything
$moduleFile = Join-Path $env:ProgramFiles 'Veeam\Backup365\Veeam.Archiver.PowerShell\Veeam.Archiver.PowerShell.psd1'
if (-not (Get-Module -ListAvailable -Name Veeam.Archiver.PowerShell) -and -not (Test-Path $moduleFile)) {
throw 'The Veeam Backup for Microsoft 365 PowerShell module (Veeam.Archiver.PowerShell) is not installed on this server.'
}
if (-not $Tenant -and $OrganizationTenants.Count -eq 0) {
Write-Warning 'No -Tenant or -OrganizationTenants: jobs will be listed under their Microsoft 365 organization name.'
}
# Shared installer code: in the repo it's in ..\common, so copy the whole collectors folder
$sharedModule = @(
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $sharedModule) {
throw "BackupDashboard.Collector.psm1 not found. Copy the whole collectors folder, including common."
}
Import-Module $sharedModule -Force
$settings = [ordered]@{
SourceSystem = $SourceSystem
LookbackHours = $LookbackHours
OrganizationTenants = $OrganizationTenants
}
if ($Tenant) { $settings.Tenant = $Tenant }
Install-CollectorTask -Name 'vb365' -ScriptPath (Join-Path $PSScriptRoot 'Send-Vb365BackupStatus.ps1') `
-TaskName 'Backup Dashboard - VB365 collector' `
-TaskDescription 'Sends Veeam Backup for Microsoft 365 results to the Backup Dashboard' `
-Shell $Shell -ApiUrl $ApiUrl -IntervalMinutes $IntervalMinutes -InstallDir $InstallDir -Settings $settings
+88
View File
@@ -0,0 +1,88 @@
# Veeam Backup for Microsoft 365 collector
A PowerShell script that runs on the Veeam Backup for Microsoft 365 (VB365) server and reports its
backup jobs (and backup copy jobs) to the dashboard, one dashboard job per VB365 job.
> **Checked against:** VB365 8.4 under Windows PowerShell 5.1, a shared server protecting 11
> organizations: successful, warning and retried sessions. Not yet seen on a real server: a failed
> session, a session in progress, or a backup copy job. If one looks wrong, run
> `Get-Vb365CollectorDiagnostics.ps1` while it's in that state and send the output.
## Which company a job appears under
One VB365 server can protect several Microsoft 365 organizations, so the company is picked per job
from the job's organization:
1. `OrganizationTenants` maps an organization name (as `Get-VBOOrganization` shows it, e.g.
`acme.onmicrosoft.com`; not case-sensitive) to a dashboard company.
2. Otherwise `Tenant`, for a VB365 server dedicated to one client.
3. Otherwise the organization's own name.
Changing a job's company later creates a new dashboard job under the new company; delete the old
one with `python manage.py delete-job <id>`.
## Install (on the VB365 server, as an administrator)
1. On the dashboard host, create a key for this server:
```bash
python manage.py create-key vb365-01
```
2. Copy the whole `collectors` folder to the server (the installer needs `common`
as well as this folder), then from `collectors\vb365` run one of:
```powershell
# VB365 server for one client
.\Install-Vb365Collector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
# Shared VB365 server
.\Install-Vb365Collector.ps1 -ApiUrl https://backups.example.com -OrganizationTenants @{
'acme.onmicrosoft.com' = 'Acme Corp'
'bluewater.onmicrosoft.com' = 'Bluewater Dental'
}
```
It prompts for the key, then:
- writes `C:\ProgramData\BackupDashboard\vb365-collector.json` (readable only by SYSTEM and Administrators)
- copies the script to `C:\Program Files\BackupDashboard\`
- registers the scheduled task **Backup Dashboard - VB365 collector** (SYSTEM, every 60 minutes)
It runs under Windows PowerShell 5.1. If your VB365 version's module needs PowerShell 7, add
`-PwshPath 'C:\Program Files\PowerShell\7\pwsh.exe'`.
3. Preview the payloads, then backfill 30 days so the history view is populated straight away:
```powershell
powershell.exe -File 'C:\Program Files\BackupDashboard\Send-Vb365BackupStatus.ps1' -DryRun
powershell.exe -File 'C:\Program Files\BackupDashboard\Send-Vb365BackupStatus.ps1' -LookbackHours 720
```
It can be installed alongside the other collectors: it uses its own config, state, log and task.
Re-run the installer to change settings (including the organization mapping) or update the script. It also installs
`BackupDashboard.Collector.psm1`, the code shared by all collectors, next to the script.
## What it sends
| Dashboard | VB365 |
|---|---|
| Company | See above |
| Job | Each backup job and backup copy job. The job's GUID is the ID, so renaming it in VB365 doesn't duplicate it |
| Status | Session status: Success / Warning / Failed. Running or queued → **Running**. Stopped (cancelled) or unrecognised → **Warning** |
| Started / finished | Session `CreationTime` / `EndTime` |
| Next scheduled | Job's `NextRun`. Empty for disabled jobs |
| Message | Warning and error lines from the session log (items titled `[Warning] ...`, `[Failed] ...`), plus "Veeam will retry this job." when a retry is pending |
| Retries | VB365 runs a retry as a separate session, so it appears as its own run, marked `details.retry_of` (the session it retried). The retried run is marked `details.will_retry` |
| Size | Data transferred (`Statistics.TransferredData`) |
| Details | Organization, objects processed, job kind (`backup` / `copy`) |
Each run covers the last 48 hours (`LookbackHours`). Sessions already delivered are recorded in
`vb365-collector.state.json`.
## Troubleshooting
- Log: `C:\ProgramData\BackupDashboard\vb365-collector.log`. Exit codes: `0` OK, `1` some jobs
failed to send (retried next run), `2` setup failed (config or VB365 module).
- Wrong status, times or messages: run `.\Get-Vb365CollectorDiagnostics.ps1` (read-only). It prints
the real structure of jobs and sessions, including whether each date is local or UTC.
## Testing without VB365
```powershell
. ./tests/MockVb365.ps1
./Send-Vb365BackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
```
+278
View File
@@ -0,0 +1,278 @@
<#
.SYNOPSIS
Reports Veeam Backup for Microsoft 365 (VB365) job results to the Backup Dashboard.
.DESCRIPTION
Runs on the VB365 server, normally as a SYSTEM scheduled task every 60 minutes
(see Install-Vb365Collector.ps1). For every backup job (and backup copy job) it sends:
- the job's next scheduled run, which drives the dashboard's Overdue flag, and
- every session from the last LookbackHours that the dashboard hasn't already received in
its final state. Running sessions are re-sent each time until they finish.
One VB365 server can protect several Microsoft 365 organizations, so the dashboard company is
chosen per job from its organization: OrganizationTenants in the config maps an organization
name (e.g. "contoso.onmicrosoft.com") to a company; otherwise Tenant is used; otherwise the
organization's own name.
Already-reported sessions are tracked in a state file, and the ingest API is idempotent, so a
failed or repeated run is harmless: anything not confirmed is retried next time.
.PARAMETER ConfigPath
JSON config with ApiUrl and ApiKey (optional: Tenant, OrganizationTenants, SourceSystem,
LookbackHours).
.PARAMETER LookbackHours
Overrides the config value. Use 720 once to backfill 30 days of history.
.PARAMETER DryRun
Print the payloads instead of sending them. Doesn't touch the state or log files.
.EXAMPLE
.\Send-Vb365BackupStatus.ps1 -DryRun
.EXAMPLE
.\Send-Vb365BackupStatus.ps1 -LookbackHours 720
#>
[CmdletBinding()]
param(
[string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\vb365-collector.json'),
[int]$LookbackHours = 0,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
# Shared collector code: installed next to this script; in the repo, in ..\common
$sharedModule = @(
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $sharedModule) {
Write-Host "BackupDashboard.Collector.psm1 not found next to $PSCommandPath; re-run the installer."
exit 2
}
Import-Module $sharedModule -Force
$Invariant = [Globalization.CultureInfo]::InvariantCulture
# ---------- Helpers ----------
function Import-Vb365 {
if (Get-Command Get-VBOJob -ErrorAction SilentlyContinue) { return } # already loaded
if (Get-Module -ListAvailable -Name Veeam.Archiver.PowerShell) {
Import-Module Veeam.Archiver.PowerShell
return
}
# Fall back to the default install path
Import-Module (Join-Path $env:ProgramFiles 'Veeam\Backup365\Veeam.Archiver.PowerShell\Veeam.Archiver.PowerShell.psd1')
}
function Get-Time($Object, [string]$Field) {
$value = Get-FirstValue $Object @($Field)
if ($null -eq $value) { return $null }
$dt = [datetime]$value
if ($dt.Year -le 1900) { return $null } # e.g. EndTime while a session is running
return $dt
}
# "1.5 GB", "512 MB", "123456" -> bytes; $null when it can't be read
function ConvertTo-Bytes($Value) {
if ($null -eq $Value) { return $null }
if ($Value -is [ValueType]) { return [long]$Value }
$match = [regex]::Match([string]$Value, '^\s*([\d.,]+)\s*([KMGTP]?B)?\s*$', 'IgnoreCase')
if (-not $match.Success) { return $null }
$number = 0.0
if (-not [double]::TryParse(($match.Groups[1].Value -replace ',', '.'), [Globalization.NumberStyles]::Float, $Invariant, [ref]$number)) {
return $null
}
$power = @{ '' = 0; 'B' = 0; 'KB' = 1; 'MB' = 2; 'GB' = 3; 'TB' = 4; 'PB' = 5 }[$match.Groups[2].Value.ToUpperInvariant()]
return [long]($number * [math]::Pow(1024, $power))
}
# ---------- Jobs and tenants ----------
function Get-OrganizationName($Job) {
$org = Get-FirstValue $Job @('Organization')
if ($null -eq $org) {
# Copy jobs point at their source backup job
$org = Get-FirstValue (Get-FirstValue $Job @('BackupJob')) @('Organization')
}
if ($org -is [string]) { return $org }
$name = Get-FirstValue $org @('Name', 'OfficeName', 'DisplayName')
if ($name) { return [string]$name }
return $null
}
function Get-TenantForJob($Job) {
$org = Get-OrganizationName $Job
if ($org -and $Config.OrganizationTenants) {
$mapped = $Config.OrganizationTenants.PSObject.Properties[$org] # case-insensitive lookup
if ($mapped -and $mapped.Value) { return [string]$mapped.Value }
}
if ($Config.Tenant) { return [string]$Config.Tenant }
if ($org) { return $org }
return $env:COMPUTERNAME
}
function Get-NextRun($Job) {
try {
$enabled = Get-FirstValue $Job @('IsEnabled', 'Enabled')
if ($enabled -eq $false) { return $null }
return ConvertTo-IsoTime (Get-Time $Job 'NextRun')
} catch {
Write-Log "Couldn't read schedule for job '$($Job.Name)': $(Get-ErrorText $_)" 'WARN'
return $null
}
}
# ---------- Sessions ----------
function Get-RunStatus($Session) {
$status = [string](Get-FirstValue $Session @('Status', 'Result', 'State'))
if ($status -match 'Fail|Error') { return 'failed' }
if ($status -match 'Warn') { return 'warning' }
if ($status -match 'Succe') { return 'success' }
if ($status -match 'Run|Queue|Progress|Start|Wait') { return 'running' }
if (-not (Get-Time $Session 'EndTime')) { return 'running' }
# Stopped (cancelled), Disconnected, or a state we don't recognise: surface it rather than hide it
return 'warning'
}
function Test-EmptyGuid($Value) {
return (-not $Value) -or [string]$Value -eq [string][guid]::Empty
}
# Warning/error lines from the session log. Each log item's status is a prefix of its Title, e.g.
# "[Warning] Item was skipped" (there's no separate status property), so match on that rather than
# on words in the text, which would catch e.g. "[Success] Processing site: .../error-reports".
function Get-RunMessage($Session, [string]$Status) {
if ($Status -eq 'running') { return '' }
$lines = New-Object 'System.Collections.Generic.List[string]'
$sessionStatus = [string](Get-FirstValue $Session @('Status', 'Result', 'State'))
if ($Status -ne 'success') {
foreach ($entry in @(Get-FirstValue $Session @('Log', 'Logs') | Where-Object { $_ })) {
$title = ([string](Get-FirstValue $entry @('Title', 'Message', 'Text'))).Trim()
$prefix = [regex]::Match($title, '^\[(\w+)\]\s*')
if ($prefix.Success) {
if ($prefix.Groups[1].Value -notmatch 'Warn|Fail|Error') { continue }
$title = $title.Substring($prefix.Length)
} elseif ([string](Get-FirstValue $entry @('Status', 'Type', 'Severity')) -notmatch 'Warn|Fail|Error') {
continue
}
if ($title -and -not $lines.Contains($title)) { $lines.Add($title) }
}
}
if ($lines.Count -eq 0) {
if ($Status -eq 'success') { $lines.Add('Job completed successfully.') }
else { $lines.Add("Job finished with status '$sessionStatus'.") }
} elseif ($sessionStatus -and $sessionStatus -notmatch 'Fail|Warn') {
$lines.Insert(0, "Session status: $sessionStatus")
}
# A warning/failure VB365 is about to retry; the retry arrives as its own run
if ($Status -ne 'success' -and (Get-FirstValue $Session @('JobWillBeRetried')) -eq $true) {
$lines.Add('Veeam will retry this job.')
}
return ($lines -join "`n")
}
function ConvertTo-Run($Session, $Job, [string]$JobKind) {
$status = Get-RunStatus $Session
$details = [ordered]@{ vb365_server = $env:COMPUTERNAME; job_kind = $JobKind }
$org = Get-OrganizationName $Job
if ($org) { $details.organization = $org }
$sessionStatus = Get-FirstValue $Session @('Status', 'Result', 'State')
if ($sessionStatus) { $details.vb365_status = [string]$sessionStatus }
# Retries are separate sessions pointing at the session they retry
$mainSession = Get-FirstValue $Session @('MainSessionId')
if (-not (Test-EmptyGuid $mainSession)) { $details.retry_of = [string]$mainSession }
if ((Get-FirstValue $Session @('JobWillBeRetried')) -eq $true) { $details.will_retry = $true }
$run = [ordered]@{
external_id = [string](Get-FirstValue $Session @('Id', 'SessionId'))
status = $status
started_at = ConvertTo-IsoTime (Get-Time $Session 'CreationTime')
message = Get-RunMessage $Session $status
details = $details
}
if ($status -ne 'running') {
$finished = ConvertTo-IsoTime (Get-Time $Session 'EndTime')
if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time
$stats = Get-FirstValue $Session @('Statistics')
$processed = Get-FirstValue $stats @('ProcessedObjects')
if ($null -ne $processed) { $details.processed_objects = $processed }
$transferred = ConvertTo-Bytes (Get-FirstValue $stats @('TransferredData', 'TransferredDataSize'))
if ($transferred -gt 0) { $run.size_bytes = $transferred }
}
return $run
}
# ---------- Main ----------
try {
$Config = Initialize-Collector -Name 'vb365' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun -RequiredKeys 'ApiUrl', 'ApiKey'
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Veeam Backup for M365' }
$LookbackHours = $Config.LookbackHours
Import-Vb365
$since = (Get-Date).AddHours(-$LookbackHours)
# (job, kind, sessions) for backup jobs, plus backup copy jobs where the cmdlets exist
$work = New-Object 'System.Collections.Generic.List[object]'
foreach ($job in @(Get-VBOJob)) {
$sessions = @(Get-VBOJobSession -Job $job | Where-Object { (Get-Time $_ 'CreationTime') -ge $since })
$work.Add([pscustomobject]@{ Job = $job; Kind = 'backup'; Sessions = $sessions })
}
if ((Get-Command Get-VBOCopyJob -ErrorAction SilentlyContinue) -and (Get-Command Get-VBOCopyJobSession -ErrorAction SilentlyContinue)) {
$copyJobs = @(Get-VBOCopyJob)
if ($copyJobs.Count) {
# Fetch once and match by job ID, since the per-job parameter name isn't consistent
$copySessions = @(Get-VBOCopyJobSession | Where-Object { (Get-Time $_ 'CreationTime') -ge $since })
foreach ($job in $copyJobs) {
$id = [string]$job.Id
$work.Add([pscustomobject]@{
Job = $job; Kind = 'copy'
Sessions = @($copySessions | Where-Object { [string](Get-FirstValue $_ @('JobId')) -eq $id })
})
}
}
}
} catch {
Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR'
exit 2
}
$failures = 0
foreach ($item in $work) {
$job = $item.Job
$tenant = Get-TenantForJob $job
$jobInfo = [ordered]@{
external_id = [string]$job.Id
name = [string]$job.Name
# Always sent (null when disabled/unscheduled) so the dashboard's value tracks VB365's
next_run_at = Get-NextRun $job
}
$pending = @($item.Sessions |
Where-Object { $_ -and -not (Test-RunSent ([string](Get-FirstValue $_ @('Id', 'SessionId')))) } |
Sort-Object { Get-Time $_ 'CreationTime' })
try {
if ($pending.Count -eq 0) {
# Keep next_run_at fresh even when nothing new ran
Send-JobReport -Tenant $tenant -SourceSystem $SourceSystem -Job $jobInfo
}
foreach ($session in $pending) {
$run = ConvertTo-Run $session $job $item.Kind
Send-JobReport -Tenant $tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.external_id -StartedAt ((Get-Time $session 'CreationTime'))
}
} catch {
$failures++
Write-Log "Failed to report job '$($job.Name)': $(Get-ErrorText $_)" 'ERROR'
if (Test-AuthRejected $_) {
Write-Log 'API key rejected; check ApiKey in the config. Stopping.' 'ERROR'
break
}
}
}
$exitCode = Complete-Collector -JobCount $work.Count -Failures $failures
if ($exitCode) { exit $exitCode }
+78
View File
@@ -0,0 +1,78 @@
# Stand-ins for the Veeam Backup for Microsoft 365 cmdlets. Object shapes follow
# Get-Vb365CollectorDiagnostics.ps1 output from a real VB365 8.4 server: session and NextRun times
# are local, Statistics.TransferredData is a string ("187.2 MB"), log items carry their status as a
# Title prefix ("[Warning] ..."), and retries are separate sessions whose MainSessionId points at
# the session they retry (which has JobWillBeRetried = True).
#
# . ./tests/MockVb365.ps1
# ./Send-Vb365BackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same data
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
function New-StableGuid([string]$Seed) {
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
}
function New-MockOrg($Name) { [pscustomobject]@{ Name = $Name; OfficeName = $Name; Type = 'Office365'; IsBackedUp = $true } }
$acme = New-MockOrg 'acme.onmicrosoft.com'
$blue = New-MockOrg 'bluewater.onmicrosoft.com'
function New-MockJob($Name, $Org, $NextRun, [bool]$Enabled = $true) {
[pscustomobject]@{
Id = New-StableGuid $Name; Name = $Name; Organization = $Org; JobType = 'Backup'
IsEnabled = $Enabled; NextRun = $NextRun; LastStatus = 'Success'
LastRun = $(if ($NextRun) { $NextRun.AddDays(-1).ToUniversalTime() }) # UTC, as on the real server
}
}
function New-LogItem($Status, $Text, $At) {
[pscustomobject]@{ Id = [guid]::NewGuid(); USN = 1; Title = "[$Status] $Text"; CreationTime = $At; EndTime = $At }
}
function New-MockSession($Job, $Start, $End, $Status, $Transferred = '0 B', [long]$Objects = 0, $Log = @(),
$MainSessionId = [guid]::Empty, [bool]$WillRetry = $false) {
$logItems = @((New-LogItem 'Success' "Job started at: $($Start.ToString('G'))" $Start)) + @($Log)
[pscustomobject]@{
JobId = $Job.Id; JobName = $Job.Name; Status = $Status
Statistics = [pscustomobject]@{ ProcessingRate = '1.6 MB/s (4 items/s)'; TransferredData = $Transferred; ProcessedObjects = $Objects; Bottleneck = 'Target' }
JobSessionConfigType = 'Incremental'
Id = New-StableGuid "$($Job.Name)|$($Start.Ticks)"
CreationTime = $Start
EndTime = $(if ($End) { $End } else { [datetime]::MinValue })
Progress = 49; MainSessionId = $MainSessionId; JobWillBeRetried = $WillRetry
Log = $logItems
}
}
$global:MockVboJobs = @(
(New-MockJob 'Acme 365 Backup' $acme $now.Date.AddHours(17).AddDays($(if ($now.Hour -ge 17) { 1 } else { 0 }))),
(New-MockJob 'Bluewater 365 Backup' $blue $now.Date.AddHours(17.75).AddDays($(if ($now.Hour -ge 17) { 1 } else { 0 }))),
(New-MockJob 'Bluewater Old Mailboxes' $blue $now.AddDays(1) $false) # disabled
)
$acmeJob, $blueJob, $oldJob = $global:MockVboJobs
$y = $now.Date.AddDays(-1) # yesterday
$blueWarn = New-MockSession $blueJob $y.AddHours(17.75) $y.AddHours(17.75).AddMinutes(3.5) 'Warning' '32.9 MB' 47 @(
(New-LogItem 'Success' 'Found 94 objects' $y.AddHours(17.75)),
# A success line mentioning "error" must not end up in the message
(New-LogItem 'Success' 'Processing site: https://bluewater.sharepoint.com/sites/error-reports' $y.AddHours(17.76)),
(New-LogItem 'Warning' 'Processing mailbox [email protected] finished with warning: Item "Q3.pst" exceeds the maximum size' $y.AddHours(17.78))
) -WillRetry $true
$global:MockVboSessions = @(
(New-MockSession $acmeJob $y.AddHours(17) $y.AddHours(17).AddMinutes(4.8) 'Success' '187.2 MB' 568 @(
(New-LogItem 'Success' 'Found 49 objects' $y.AddHours(17)), (New-LogItem 'Success' 'Connected to organization' $y.AddHours(17)))),
(New-MockSession $acmeJob $now.AddMinutes(-20) $null 'Running' '0 B' 12),
$blueWarn,
# Its retry 13 minutes later, which succeeded
(New-MockSession $blueJob $y.AddHours(17.75).AddMinutes(13.5) $y.AddHours(17.75).AddMinutes(16.8) 'Success' '0 B' 0 -MainSessionId $blueWarn.Id),
(New-MockSession $blueJob $now.AddHours(-30) $now.AddHours(-29.9) 'Failed' '0 B' 0 @(
(New-LogItem 'Failed' 'Failed to connect to organization: The remote server returned an error: (401) Unauthorized.' $now.AddHours(-30)))),
(New-MockSession $oldJob $now.AddDays(-5) $now.AddDays(-5).AddHours(1) 'Success') # outside 48h lookback
)
function Get-VBOJob { $global:MockVboJobs }
function Get-VBOJobSession { param($Job) $global:MockVboSessions | Where-Object { -not $Job -or $_.JobId -eq $Job.Id } }
function Get-VBOOrganization { $acme, $blue }
function Get-VBOCopyJob { } # none, as on the real server
function Get-VBOCopyJobSession { }
@@ -0,0 +1,188 @@
<#
.SYNOPSIS
Read-only report of how Veeam jobs and sessions relate, for troubleshooting the collector.
.DESCRIPTION
The collector matches sessions to jobs by Session.JobId = Job.Id. When a job shows as
"Unknown" on the dashboard, no session matched. This lists:
1. every job from Get-VBRJob and how many sessions matched it
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
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.
.EXAMPLE
.\Get-VeeamCollectorDiagnostics.ps1
.EXAMPLE
.\Get-VeeamCollectorDiagnostics.ps1 -LookbackHours 168
#>
[CmdletBinding()]
param(
[int]$LookbackHours = 48,
[string]$OutFile = (Join-Path $env:TEMP 'veeam-collector-diagnostics.txt')
)
$ErrorActionPreference = 'Stop'
$LinkPattern = 'Parent|Linked|Worker|Child|Policy|Source|Target|Type|Cloud|Mode|Session'
if (-not (Get-Command Get-VBRJob -ErrorAction SilentlyContinue)) {
if (Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) {
Import-Module Veeam.Backup.PowerShell -WarningAction SilentlyContinue
} else {
Add-PSSnapin VeeamPSSnapin
}
}
function Format-Value($Value) {
if ($null -eq $Value) { return '<null>' }
$text = ($Value | ForEach-Object { [string]$_ }) -join ', '
if ($text.Length -gt 120) { $text = $text.Substring(0, 117) + '...' }
return $text
}
# Property and method names matching $LinkPattern, with property values. Every read is guarded:
# some Veeam properties throw when accessed.
function Show-LinkMembers($Object, [string]$Pattern = $LinkPattern, [string]$Indent = ' ') {
foreach ($p in $Object.PSObject.Properties | Where-Object { $_.Name -match $Pattern }) {
$value = try { Format-Value $p.Value } catch { "<error: $($_.Exception.Message)>" }
'{0}{1} = {2}' -f $Indent, $p.Name, $value
}
$methods = @($Object | Get-Member -MemberType Methods -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match $Pattern } | ForEach-Object Name)
if ($methods) { $Indent + 'methods: ' + ($methods -join ', ') }
}
function Get-Prop($Object, [string]$Name) {
try { Format-Value $Object.$Name } catch { '<error>' }
}
$report = & {
$vbrVersion = try {
(Get-Item (Join-Path ${env:ProgramFiles} 'Veeam\Backup and Replication\Backup\Veeam.Backup.Core.dll')).VersionInfo.ProductVersion
} catch { 'unknown' }
"Generated $(Get-Date -Format o) on $env:COMPUTERNAME, VBR $vbrVersion, PowerShell $($PSVersionTable.PSVersion)"
"Lookback: $LookbackHours hours"
''
$since = (Get-Date).AddHours(-$LookbackHours)
$jobs = @(Get-VBRJob -WarningAction SilentlyContinue)
$sessions = @(Get-VBRBackupSession -WarningAction SilentlyContinue | Where-Object { $_.CreationTime -ge $since })
$jobIds = @{}
foreach ($j in $jobs) { $jobIds[$j.Id.ToString()] = $j }
'== 1. Jobs from Get-VBRJob'
$jobs | ForEach-Object {
$id = $_.Id.ToString()
[pscustomobject]@{
Name = $_.Name
JobType = Get-Prop $_ 'JobType'
TypeToString = Get-Prop $_ 'TypeToString'
Id = $id
MatchingSessions = @($sessions | Where-Object { $_.JobId.ToString() -eq $id }).Count
}
} | Format-Table -AutoSize | Out-String -Width 300
'== 2. Sessions whose JobId matches no job from Get-VBRJob'
$orphans = @($sessions | Where-Object { -not $jobIds.ContainsKey($_.JobId.ToString()) })
if ($orphans.Count -eq 0) {
' (none)'
} else {
$orphans | Group-Object { $_.JobId.ToString() } | ForEach-Object {
$latest = $_.Group | Sort-Object CreationTime | Select-Object -Last 1
[pscustomobject]@{
JobId = $_.Name
JobName = Get-Prop $latest 'JobName'
JobType = Get-Prop $latest 'JobType'
Sessions = $_.Count
LatestStart = $latest.CreationTime
State = Get-Prop $latest 'State'
Result = Get-Prop $latest 'Result'
}
} | Format-Table -AutoSize | Out-String -Width 300
' Link-related members of one orphan session:'
Show-LinkMembers $orphans[0]
''
' Job object for that session (via GetJob(), if available):'
try {
$owner = $orphans[0].GetJob()
' Name = {0}, Id = {1}' -f (Get-Prop $owner 'Name'), (Get-Prop $owner 'Id')
Show-LinkMembers $owner
} catch {
" not available: $($_.Exception.Message)"
}
}
''
'== 3. Jobs with no matching sessions in the window'
$empty = @($jobs | Where-Object { $id = $_.Id.ToString(); -not ($sessions | Where-Object { $_.JobId.ToString() -eq $id }) })
if ($empty.Count -eq 0) { ' (none)' }
foreach ($j in $empty) {
" $($j.Name) [$($j.Id)]"
Show-LinkMembers $j
try {
$last = $j.FindLastSession()
if ($last) {
' FindLastSession(): JobId = {0}, JobName = {1}, Start = {2}, Result = {3}' -f `
(Get-Prop $last 'JobId'), (Get-Prop $last 'JobName'), $last.CreationTime, (Get-Prop $last 'Result')
} else {
' FindLastSession(): <null>'
}
} catch {
" FindLastSession(): not available: $($_.Exception.Message)"
}
''
}
'== 4. Other job cmdlets'
foreach ($cmd in 'Get-VBRBackupCopyJob', 'Get-VBRComputerBackupCopyJob', 'Get-VBRComputerBackupJob') {
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { " ${cmd}: not available"; continue }
try {
$found = @(& $cmd -WarningAction SilentlyContinue)
" ${cmd}: $($found.Count) job(s)"
foreach ($f in $found) {
' {0} [{1}] Type = {2}, Mode = {3}' -f (Get-Prop $f 'Name'), (Get-Prop $f 'Id'), (Get-Prop $f 'Type'), (Get-Prop $f 'Mode')
}
} catch {
" ${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
$report
$report | Set-Content -Path $OutFile -Encoding UTF8
Write-Host "Saved to $OutFile"
@@ -0,0 +1,68 @@
<#
.SYNOPSIS
Installs the Veeam collector on a VBR server as a SYSTEM scheduled task.
.DESCRIPTION
- Copies Send-VeeamBackupStatus.ps1 to Program Files (only admins can modify it, which
matters because it runs as SYSTEM).
- Writes the config to ProgramData\BackupDashboard and restricts that folder to SYSTEM and
Administrators, since it contains the API key.
- Registers a scheduled task that runs every IntervalMinutes under PowerShell 7 (pwsh.exe),
which the Veeam module on VBR 13 expects. Install PowerShell 7 machine-wide (MSI or winget)
first: Microsoft Store installs can't be run by SYSTEM.
Safe to re-run to change settings or update the script.
.EXAMPLE
.\Install-VeeamCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
(prompts for the API key)
#>
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ApiUrl,
[Parameter(Mandatory)][string]$Tenant,
[string]$SourceSystem = 'Veeam B&R',
[ValidateRange(5, 1440)][int]$IntervalMinutes = 60,
[ValidateRange(1, 720)][int]$LookbackHours = 48,
[string]$InstallDir = (Join-Path $env:ProgramFiles 'BackupDashboard'),
# Defaults to the machine-wide PowerShell 7 install
[string]$PwshPath
)
$ErrorActionPreference = 'Stop'
# Find PowerShell 7 before changing anything, so a missing install fails cleanly
if (-not $PwshPath) {
$PwshPath = Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe'
if (-not (Test-Path $PwshPath)) {
$onPath = Get-Command pwsh.exe -ErrorAction SilentlyContinue
if ($onPath) { $PwshPath = $onPath.Source }
}
}
if (-not (Test-Path $PwshPath)) {
throw "PowerShell 7 not found at '$PwshPath'. Install it machine-wide (e.g. winget install Microsoft.PowerShell) or pass -PwshPath."
}
if ($PwshPath -match '\\WindowsApps\\') {
throw "'$PwshPath' is a Microsoft Store install, which the SYSTEM account can't run. Install the MSI or winget version instead."
}
$pwshVersion = & $PwshPath -NoProfile -NonInteractive -Command '$PSVersionTable.PSVersion.ToString()'
$pwshMajor = & $PwshPath -NoProfile -NonInteractive -Command '$PSVersionTable.PSVersion.Major'
if ($LASTEXITCODE -or ($pwshMajor -as [int]) -lt 7) {
throw "'$PwshPath' isn't a working PowerShell 7 (reported '$pwshVersion')."
}
# Shared installer code: in the repo it's in ..\common, so copy the whole collectors folder
$sharedModule = @(
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $sharedModule) {
throw "BackupDashboard.Collector.psm1 not found. Copy the whole collectors folder, including common."
}
Import-Module $sharedModule -Force
$settings = [ordered]@{ Tenant = $Tenant; SourceSystem = $SourceSystem; LookbackHours = $LookbackHours }
Install-CollectorTask -Name 'veeam' -ScriptPath (Join-Path $PSScriptRoot 'Send-VeeamBackupStatus.ps1') `
-TaskName 'Backup Dashboard - Veeam collector' `
-TaskDescription 'Sends Veeam job results to the Backup Dashboard' `
-Shell $PwshPath -ApiUrl $ApiUrl -IntervalMinutes $IntervalMinutes -InstallDir $InstallDir -Settings $settings
+83
View File
@@ -0,0 +1,83 @@
# Veeam Backup & Replication collector
A PowerShell script that runs on each client's VBR server and reports job results to the dashboard.
It covers jobs returned by `Get-VBRJob`: VM backup, replication and backup copy jobs.
## Install (on the VBR server, as an administrator)
Requires PowerShell 7 installed machine-wide (`winget install Microsoft.PowerShell` or the MSI),
which VBR 13's Veeam module expects. The Microsoft Store version won't work, because the SYSTEM
account can't run it. The installer checks for this before changing anything.
1. On the dashboard host, create a key for this server:
```bash
python manage.py create-key veeam-acme
```
2. Copy the whole `collectors` folder to the VBR server (the installer needs `common`
as well as this folder), then from `collectors\veeam` run, from PowerShell 7 (`pwsh`):
```powershell
.\Install-VeeamCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
```
It prompts for the key, then:
- writes `C:\ProgramData\BackupDashboard\veeam-collector.json` (readable only by SYSTEM and Administrators)
- copies the script to `C:\Program Files\BackupDashboard\`
- registers the scheduled task **Backup Dashboard - Veeam collector** (SYSTEM, every 60 minutes,
run by `C:\Program Files\PowerShell\7\pwsh.exe`; use `-PwshPath` if it's installed elsewhere)
3. Preview the payloads, then backfill 30 days so the history view is populated straight away
(from `pwsh`):
```powershell
& 'C:\Program Files\BackupDashboard\Send-VeeamBackupStatus.ps1' -DryRun
& 'C:\Program Files\BackupDashboard\Send-VeeamBackupStatus.ps1' -LookbackHours 720
```
Re-run the installer to change settings or update the script. It also installs
`BackupDashboard.Collector.psm1`, the code shared by all collectors, next to the script. To remove the collector,
delete the scheduled task and both `BackupDashboard` folders.
## What it sends
| Dashboard | Veeam |
|---|---|
| Company | `Tenant` from the config |
| 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** |
| Started / finished | Session `CreationTime` / `EndTime` |
| 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 |
| Size | `BackupStats.BackupSize` |
**Backup copy jobs (VBR 12+):** Veeam records their sessions under hidden per-source "worker" jobs.
The collector files each worker session under its copy job (via the worker's `PolicyJobId`), so a
copy job gets one run per source job it copies, with the source in `copied_from`.
Each run covers the last 48 hours (`LookbackHours`), so a few missed runs (server rebooting, API
down) fill in automatically. Sessions already delivered are recorded in
`veeam-collector.state.json`. Running sessions are re-sent until they finish.
## Troubleshooting
- Log: `C:\ProgramData\BackupDashboard\veeam-collector.log` (rotates at 5 MB).
- Exit codes: `0` OK, `1` some jobs failed to send (they're retried next run), `2` setup failed
(config, Veeam module, or Veeam connection).
- `API key rejected`: the key was revoked or mistyped. Re-run the installer with a new key.
- A job shows **Unknown** on the dashboard: the collector sent the job but matched no sessions to it.
Run `.\Get-VeeamCollectorDiagnostics.ps1` (read-only). It lists sessions whose job ID matches no
job, plus the type and link properties of the jobs left without sessions.
- `Get-VBRBackupSession` loads every session in the VBR database. On a server with years of history,
this can make each run take a minute or more. The task's 30-minute limit and "don't start if
already running" setting keep that safe.
## Testing without Veeam
`tests/MockVeeam.ps1` defines stand-in `Get-VBRJob` / `Get-VBRBackupSession` cmdlets with a mix of
success, warning, failed, running and disabled jobs:
```powershell
. ./tests/MockVeeam.ps1
./Send-VeeamBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
```
## Not covered yet
- Veeam Agent jobs managed by VBR (`Get-VBRComputerBackupJob`), tape jobs, and SureBackup.
- Veeam Backup for Microsoft 365, which has its own PowerShell module.
+266
View File
@@ -0,0 +1,266 @@
<#
.SYNOPSIS
Reports Veeam Backup & Replication job results to the Backup Dashboard.
.DESCRIPTION
Runs on the VBR server, normally as a SYSTEM scheduled task every 60 minutes
(see Install-VeeamCollector.ps1). For every backup, replica and backup copy job it sends:
- the job's next scheduled run (drives the dashboard's Overdue flag), and
- every session from the last LookbackHours that the dashboard hasn't already received
in its final state. Running sessions are re-sent each time until they finish.
Already-reported sessions are tracked in a state file, and the ingest API is idempotent,
so a failed or repeated run is harmless: anything not confirmed is retried next time.
Compatible with Windows PowerShell 5.1 and PowerShell 7.
.PARAMETER ConfigPath
JSON config with ApiUrl, ApiKey and Tenant (optional: SourceSystem, LookbackHours).
.PARAMETER LookbackHours
Overrides the config value. Use 720 once to backfill 30 days of history.
.PARAMETER DryRun
Print the payloads instead of sending them. Doesn't touch the state or log files.
.EXAMPLE
.\Send-VeeamBackupStatus.ps1 -DryRun
.EXAMPLE
.\Send-VeeamBackupStatus.ps1 -LookbackHours 720
#>
[CmdletBinding()]
param(
[string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\veeam-collector.json'),
[int]$LookbackHours = 0,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
# Shared collector code: installed next to this script; in the repo, in ..\common
$sharedModule = @(
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $sharedModule) {
Write-Host "BackupDashboard.Collector.psm1 not found next to $PSCommandPath; re-run the installer."
exit 2
}
Import-Module $sharedModule -Force
# ---------- Helpers ----------
function Import-Veeam {
if (Get-Command Get-VBRJob -ErrorAction SilentlyContinue) { return } # already loaded
if (Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) {
Import-Module Veeam.Backup.PowerShell -WarningAction SilentlyContinue # VBR 11+
} else {
Add-PSSnapin VeeamPSSnapin # VBR 10 and earlier
}
}
function Get-NextRun($Job) {
try {
if (-not $Job.IsScheduleEnabled) { return $null }
# 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 ($raw -is [datetime]) { return ConvertTo-IsoTime $raw }
# A string formatted by VBR ("2026-10-09 12:15:00 AM" on VBR 13, en-CA). Try the server's
# culture first, then the invariant one
foreach ($culture in [Globalization.CultureInfo]::CurrentCulture, [Globalization.CultureInfo]::InvariantCulture) {
$parsed = [datetime]::MinValue
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'
} catch {
Write-Log "Couldn't read schedule for job '$($Job.Name)': $(Get-ErrorText $_)" 'WARN'
}
return $null
}
function Get-RunStatus($Session) {
if ([string]$Session.State -ne 'Stopped') { return 'running' }
switch ([string]$Session.Result) {
'Success' { return 'success' }
'Warning' { return 'warning' }
'Failed' { return 'failed' }
# Stopped without a result (e.g. cancelled): surface it rather than hide it
default { return 'warning' }
}
}
# Builds a readable summary: the session's reason, any task (VM/object) that didn't succeed,
# and warning/error lines from the session log.
function Get-RunMessage($Session, [string]$Status, $Tasks) {
$lines = New-Object 'System.Collections.Generic.List[string]'
try {
$reason = [string]$Session.Info.Reason
if ($reason.Trim()) { $lines.Add($reason.Trim()) }
} catch { }
foreach ($task in $Tasks) {
$taskStatus = [string]$task.Status
if ($taskStatus -and $taskStatus -ne 'Success' -and $taskStatus -ne 'InProgress' -and $taskStatus -ne 'Pending') {
$taskReason = ''
try { $taskReason = ([string]$task.Info.Reason).Trim() } catch { }
$line = "$($task.Name): $taskStatus"
if ($taskReason) { $line += " - $taskReason" }
$lines.Add($line)
}
}
if ($Status -eq 'warning' -or $Status -eq 'failed') {
try {
foreach ($record in $Session.Logger.GetLog().UpdatedRecords) {
$recordStatus = [string]$record.Status
if ($recordStatus -eq 'EWarning' -or $recordStatus -eq 'EFailed') {
$title = ([string]$record.Title).Trim()
if ($title -and -not $lines.Contains($title)) { $lines.Add($title) }
}
}
} catch { }
}
if ($lines.Count -eq 0) {
switch ($Status) {
'success' { return 'Job completed successfully.' }
'running' { return '' }
default { return "Job finished with result '$($Session.Result)'." }
}
}
return ($lines -join "`n")
}
# VBR 12+ backup copy jobs: Get-VBRJob returns the copy (policy) job, but Get-VBRBackupSession
# returns only the sessions of its hidden worker jobs, one per source job, never the policy's own.
# Each worker job records its policy in PolicyJobId, so those sessions can be filed under it.
function Get-PolicyJobId($Session, [hashtable]$KnownJobIds) {
try {
$policyId = [string]$Session.GetJob().PolicyJobId
if ($policyId -and $KnownJobIds.ContainsKey($policyId)) { return $policyId }
} catch { }
return ''
}
function ConvertTo-Run($Session, $Job) {
$status = Get-RunStatus $Session
$tasks = @()
try { $tasks = @($Session.GetTaskSessions()) } catch { }
$details = [ordered]@{
job_type = [string]$Job.JobType
vbr_server = $env:COMPUTERNAME
objects = $tasks.Count
}
$failedTasks = @($tasks | Where-Object { [string]$_.Status -eq 'Failed' }).Count
$warningTasks = @($tasks | Where-Object { [string]$_.Status -eq 'Warning' }).Count
if ($failedTasks) { $details.objects_failed = $failedTasks }
if ($warningTasks) { $details.objects_warning = $warningTasks }
try { if ($Session.IsRetryMode) { $details.retry = $true } } catch { }
# Worker session of a backup copy job: say which source job it copied
# (worker sessions are named "<copy job>\<source job>")
if ($Session.JobId.ToString() -ne $Job.Id.ToString()) {
$details.copied_from = ([string]$Session.JobName -split '\\')[-1]
}
$run = [ordered]@{
external_id = $Session.Id.ToString()
status = $status
started_at = ConvertTo-IsoTime $Session.CreationTime
message = Get-RunMessage $Session $status $tasks
details = $details
}
if ($status -ne 'running') {
$finished = ConvertTo-IsoTime $Session.EndTime
if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time
try {
$size = [long]$Session.BackupStats.BackupSize
if ($size -gt 0) { $run.size_bytes = $size }
$dataSize = [long]$Session.BackupStats.DataSize
if ($dataSize -gt 0) { $details.data_size_bytes = $dataSize }
} catch { }
}
return $run
}
# ---------- Main ----------
try {
$Config = Initialize-Collector -Name 'veeam' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Veeam B&R' }
$LookbackHours = $Config.LookbackHours
Import-Veeam
$since = (Get-Date).AddHours(-$LookbackHours)
$jobs = @(Get-VBRJob -WarningAction SilentlyContinue)
$jobIds = @{}
foreach ($j in $jobs) { $jobIds[$j.Id.ToString()] = $true }
# Get-VBRBackupSession returns every session in the database, so fetch once and bucket by job
$sessionsByJob = @{}
$policyByWorker = @{} # worker job ID -> policy job ID ('' if it isn't a worker of a listed job)
foreach ($s in Get-VBRBackupSession -WarningAction SilentlyContinue) {
if ($s.CreationTime -lt $since) { continue }
$key = $s.JobId.ToString()
if (-not $jobIds.ContainsKey($key)) {
if (-not $policyByWorker.ContainsKey($key)) { $policyByWorker[$key] = Get-PolicyJobId $s $jobIds }
if (-not $policyByWorker[$key]) { continue }
$key = $policyByWorker[$key]
}
if (-not $sessionsByJob.ContainsKey($key)) { $sessionsByJob[$key] = New-Object 'System.Collections.Generic.List[object]' }
$sessionsByJob[$key].Add($s)
}
} catch {
Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR'
exit 2
}
$failures = 0
foreach ($job in $jobs) {
$jobInfo = [ordered]@{
external_id = $job.Id.ToString()
name = $job.Name
# Always sent (null when unscheduled/disabled) so the dashboard's value tracks Veeam's
next_run_at = Get-NextRun $job
}
$pending = @()
if ($sessionsByJob.ContainsKey($jobInfo.external_id)) {
$pending = @($sessionsByJob[$jobInfo.external_id] |
Where-Object { -not (Test-RunSent ($_.Id.ToString())) } |
Sort-Object CreationTime)
}
try {
if ($pending.Count -eq 0) {
# Keep next_run_at fresh even when nothing new ran
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo
}
foreach ($session in $pending) {
$run = ConvertTo-Run $session $job
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.external_id -StartedAt ([datetime]$session.CreationTime)
}
} catch {
$failures++
Write-Log "Failed to report job '$($job.Name)': $(Get-ErrorText $_)" 'ERROR'
if (Test-AuthRejected $_) {
Write-Log 'API key rejected; check ApiKey in the config. Stopping.' 'ERROR'
break
}
}
}
$exitCode = Complete-Collector -JobCount $jobs.Count -Failures $failures
if ($exitCode) { exit $exitCode }
+93
View File
@@ -0,0 +1,93 @@
# Stand-ins for the Veeam cmdlets, shaped like the real objects' properties the collector reads.
# Dot-source this before running the collector to try it without a VBR server:
#
# . ./tests/MockVeeam.ps1
# ./Send-VeeamBackupStatus.ps1 -ConfigPath ./tests/config.json -DryRun
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same
# jobs and sessions, like a real server
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
function New-StableGuid([string]$Seed) {
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
}
$notSet = Get-Date '1900-01-01'
# As on VBR 13: the ScheduleOptions property is a stale snapshot with an empty NextRun, and the live
# 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
IsScheduleEnabled = $Enabled
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) {
$s = [pscustomobject]@{
Id = New-StableGuid "$($Job.Name)|$($Start.Ticks)"; JobId = $Job.Id; JobName = $Job.Name
CreationTime = $Start; EndTime = $End; State = $State; Result = $Result
IsRetryMode = $false
Info = [pscustomobject]@{ Reason = $Reason }
BackupStats = [pscustomobject]@{ BackupSize = $Size; DataSize = $Size * 3 }
Logger = [pscustomobject]@{ Records = $LogRecords }
Tasks = $Tasks
}
$s.Logger | Add-Member ScriptMethod GetLog { [pscustomobject]@{ UpdatedRecords = $this.Records } }
$s | Add-Member ScriptMethod GetTaskSessions { $this.Tasks }
$s
}
function New-MockTask($Name, $Status, $Reason = '') {
[pscustomobject]@{ Name = $Name; Status = $Status; Info = [pscustomobject]@{ Reason = $Reason } }
}
$global:MockJobs = @(
(New-MockJob 'File Server – Nightly' 'Backup' (Format-VbrTime $now.Date.AddDays(1).AddMinutes(15))),
# Some versions return NextRun as a DateTime
(New-MockJob 'SQL01 – Logs' 'Backup' $now.AddMinutes(40)),
(New-MockJob 'Offsite Copy' 'BackupSync' $null),
(New-MockJob 'Old Exchange' 'Backup' $now.AddDays(1) $false),
# VBR 12+ backup copy job (immediate mode, no schedule): the "policy" job Get-VBRJob returns
(New-MockJob 'Cloud Copy' 'SimpleBackupCopyPolicy' (Format-VbrTime $now.AddMonths(-6)) -Continuous)
)
$fs, $sql, $copy, $old, $cloud = $global:MockJobs
# Its hidden worker job, one per source job. Get-VBRJob doesn't return it, and Get-VBRBackupSession
# returns only the worker's sessions. Shape taken from a real VBR 13 server.
$cloudWorker = [pscustomobject]@{
Id = New-StableGuid 'Cloud Copy\File Server – Nightly'; Name = 'Cloud Copy\File Server – Nightly'
JobType = 'SimpleBackupCopyWorker'; PolicyJobId = $cloud.Id
}
function New-MockWorkerSession($Start, $End, $Result) {
$s = New-MockSession $cloudWorker $Start $End 'Stopped' $Result -Size 40GB
$s | Add-Member ScriptMethod GetJob { $cloudWorker }
$s
}
$global:MockSessions = @(
(New-MockSession $fs $now.AddDays(-1).AddHours(-2) $now.AddDays(-1).AddHours(-1) 'Stopped' 'Success' -Size 128GB -Tasks @(
(New-MockTask 'FS01' 'Success'), (New-MockTask 'FS02' 'Success'))),
(New-MockSession $fs $now.AddMinutes(-20) $notSet 'Working' 'None' -Tasks @((New-MockTask 'FS01' 'InProgress'))),
(New-MockSession $sql $now.AddHours(-1) $now.AddMinutes(-55) 'Stopped' 'Warning' -Size 2GB `
-Tasks @((New-MockTask 'SQL01' 'Warning' 'Failed to truncate transaction logs for database "ERP_PROD"'))),
(New-MockSession $copy $now.AddHours(-5) $now.AddHours(-4) 'Stopped' 'Failed' 'Error: Unable to connect to repository NAS02' `
-LogRecords @(
[pscustomobject]@{ Title = 'Preparing backup chain'; Status = 'ESucceeded' },
[pscustomobject]@{ Title = 'Network path not found (0x80070035)'; Status = 'EFailed' })),
(New-MockSession $old $now.AddDays(-10) $now.AddDays(-10).AddHours(1) 'Stopped' 'Success'), # outside 48h lookback
(New-MockWorkerSession $now.AddDays(-1).AddMinutes(-55) $now.AddDays(-1).AddMinutes(-30) 'Success'),
(New-MockWorkerSession $now.AddMinutes(-55) $now.AddMinutes(-35) 'Warning'),
# A session from a job the collector doesn't know (e.g. deleted): must be ignored, not crash
(New-MockSession ([pscustomobject]@{ Id = New-StableGuid 'gone'; Name = 'Deleted job' }) $now.AddHours(-2) $now.AddHours(-1) 'Stopped' 'Success')
)
function Get-VBRJob { [CmdletBinding()] param() $global:MockJobs }
function Get-VBRBackupSession { [CmdletBinding()] param() $global:MockSessions }
@@ -0,0 +1,103 @@
<#
.SYNOPSIS
Read-only dump of what the Windows Server Backup cmdlets return, for troubleshooting the collector.
.DESCRIPTION
The collector reads properties of Get-WBJob / Get-WBSummary results whose names and time zones
aren't fully documented. This prints those objects' real structure (property names, types and
values, a few levels deep), plus recent events from the Microsoft-Windows-Backup log, whose
timestamps are reliable local times to compare against. Nothing is changed. Output is also
saved to a text file.
.EXAMPLE
.\Get-WsbCollectorDiagnostics.ps1
#>
[CmdletBinding()]
param(
[int]$Jobs = 3,
[int]$Events = 15,
[string]$OutFile = (Join-Path $env:TEMP 'wsb-collector-diagnostics.txt')
)
$ErrorActionPreference = 'Stop'
$MaxDepth = 6
if (-not (Get-Command Get-WBJob -ErrorAction SilentlyContinue)) {
Import-Module WindowsServerBackup
}
function Test-Scalar($Value) {
return $null -eq $Value -or $Value -is [string] -or $Value -is [ValueType] -or $Value -is [guid]
}
# Recursive "name (type) = value" listing. Collections show their first 3 items.
function Show-Object($Value, [string]$Indent = ' ', [int]$Depth = 0) {
if (Test-Scalar $Value) { return }
if ($Depth -ge $MaxDepth) { "$Indent..."; return }
if ($Value -is [Collections.IEnumerable]) {
$items = @($Value)
"$Indent[$($items.Count) item(s)]"
$i = 0
foreach ($item in $items | Select-Object -First 3) {
"$Indent[$i] ($($item.GetType().FullName))$(if (Test-Scalar $item) { " = $item" })"
Show-Object $item "$Indent " ($Depth + 1)
$i++
}
return
}
foreach ($p in $Value.PSObject.Properties) {
try { $v = $p.Value } catch { "$Indent$($p.Name) = <error: $($_.Exception.Message)>"; continue }
$type = if ($null -eq $v) { 'null' } else { $v.GetType().FullName }
if (Test-Scalar $v) {
"$Indent$($p.Name) ($type) = $v"
} else {
$text = [string]$v
if ($text.Length -gt 100) { $text = $text.Substring(0, 97) + '...' }
"$Indent$($p.Name) ($type) = $text"
Show-Object $v "$Indent " ($Depth + 1)
}
}
}
function Show-Section([string]$Title, [scriptblock]$Get) {
"== $Title"
try {
$result = @(& $Get)
if ($result.Count -eq 0 -or ($result.Count -eq 1 -and $null -eq $result[0])) { ' (nothing returned)' }
$n = 0
foreach ($item in $result) {
if ($null -eq $item) { continue }
" [$n] ($($item.GetType().FullName))"
Show-Object $item ' '
$n++
}
} catch {
" error: $($_.Exception.Message)"
}
''
}
$report = & {
"Generated $(Get-Date -Format o) on $env:COMPUTERNAME, PowerShell $($PSVersionTable.PSVersion)"
"Local time zone: $([TimeZoneInfo]::Local.Id), UTC offset now $([TimeZoneInfo]::Local.GetUtcOffset((Get-Date)))"
''
Show-Section 'Get-WBSummary' { Get-WBSummary }
Show-Section 'Get-WBJob (in progress)' { Get-WBJob }
Show-Section "Get-WBJob -Previous $Jobs" { Get-WBJob -Previous $Jobs }
Show-Section 'Get-WBPolicy | Get-WBSchedule' {
$policy = Get-WBPolicy
if ($policy) { Get-WBSchedule -Policy $policy } else { '(no scheduled backup policy)' }
}
"== Microsoft-Windows-Backup event log (last $Events, local times)"
try {
Get-WinEvent -LogName 'Microsoft-Windows-Backup' -MaxEvents $Events |
Select-Object TimeCreated, Id, LevelDisplayName, @{ n = 'Message'; e = { ($_.Message -split "`r?`n")[0] } } |
Format-Table -AutoSize -Wrap | Out-String -Width 300
} catch {
" error: $($_.Exception.Message)"
}
} | Out-String -Width 300
$report
$report | Set-Content -Path $OutFile -Encoding UTF8
Write-Host "Saved to $OutFile"
+54
View File
@@ -0,0 +1,54 @@
<#
.SYNOPSIS
Installs the Windows Server Backup collector as a SYSTEM scheduled task.
.DESCRIPTION
- Copies Send-WsbBackupStatus.ps1 to Program Files (only admins can modify it, which
matters because it runs as SYSTEM).
- Writes the config to ProgramData\BackupDashboard and restricts that folder to SYSTEM and
Administrators, since it contains the API key.
- Registers a scheduled task that runs every IntervalMinutes under Windows PowerShell 5.1
(powershell.exe), which the WindowsServerBackup module targets.
Safe to re-run to change settings or update the script. Can sit alongside the other collectors.
.EXAMPLE
.\Install-WsbCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
(prompts for the API key)
#>
#Requires -RunAsAdministrator
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$ApiUrl,
[Parameter(Mandatory)][string]$Tenant,
# Name shown on the dashboard; defaults to "<server> - Windows Server Backup"
[string]$JobName,
[string]$SourceSystem = 'Windows Server Backup',
[ValidateRange(5, 1440)][int]$IntervalMinutes = 60,
[ValidateRange(1, 720)][int]$LookbackHours = 48,
[string]$InstallDir = (Join-Path $env:ProgramFiles 'BackupDashboard')
)
$ErrorActionPreference = 'Stop'
$WindowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
# Check Windows Server Backup is here before changing anything
if (-not (Get-Module -ListAvailable -Name WindowsServerBackup)) {
throw 'The WindowsServerBackup PowerShell module is not available. Install the Windows Server Backup feature (Install-WindowsFeature Windows-Server-Backup).'
}
# Shared installer code: in the repo it's in ..\common, so copy the whole collectors folder
$sharedModule = @(
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $sharedModule) {
throw "BackupDashboard.Collector.psm1 not found. Copy the whole collectors folder, including common."
}
Import-Module $sharedModule -Force
$settings = [ordered]@{ Tenant = $Tenant; SourceSystem = $SourceSystem; LookbackHours = $LookbackHours }
if ($JobName) { $settings.JobName = $JobName }
Install-CollectorTask -Name 'wsb' -ScriptPath (Join-Path $PSScriptRoot 'Send-WsbBackupStatus.ps1') `
-TaskName 'Backup Dashboard - WSB collector' `
-TaskDescription 'Sends Windows Server Backup results to the Backup Dashboard' `
-Shell $WindowsPowerShell -ApiUrl $ApiUrl -IntervalMinutes $IntervalMinutes -InstallDir $InstallDir -Settings $settings
+71
View File
@@ -0,0 +1,71 @@
# Windows Server Backup collector
A PowerShell script that runs on each server using Windows Server Backup (WSB) and reports its
backups to the dashboard. Each server appears as one job, named `<SERVER> - Windows Server Backup`
by default.
> **Checked against:** Server 2019 Hyper-V host backing up VMs, successful backups only.
> Not yet seen on a real server: a failed backup, a failed VM or volume, or a backup in progress.
> Those paths are written to the same object model, but if one looks wrong, run
> `Get-WsbCollectorDiagnostics.ps1` while it's in that state and send the output.
## Install (on the server, as an administrator)
Requires the Windows Server Backup feature with its PowerShell module
(`Install-WindowsFeature Windows-Server-Backup`). Runs under Windows PowerShell 5.1
(`powershell.exe`), which that module targets.
1. On the dashboard host, create a key for this server:
```bash
python manage.py create-key wsb-acme-dc01
```
2. Copy the whole `collectors` folder to the server (the installer needs `common`
as well as this folder), then from `collectors\wsb` run:
```powershell
.\Install-WsbCollector.ps1 -ApiUrl https://backups.example.com -Tenant "Acme Corp"
```
Add `-JobName "Domain Controller"` to choose the name shown on the dashboard. It prompts for the
key, then:
- writes `C:\ProgramData\BackupDashboard\wsb-collector.json` (readable only by SYSTEM and Administrators)
- copies the script to `C:\Program Files\BackupDashboard\`
- registers the scheduled task **Backup Dashboard - WSB collector** (SYSTEM, every 60 minutes)
3. Preview the payloads, then backfill 30 days so the history view is populated straight away:
```powershell
powershell.exe -File 'C:\Program Files\BackupDashboard\Send-WsbBackupStatus.ps1' -DryRun
powershell.exe -File 'C:\Program Files\BackupDashboard\Send-WsbBackupStatus.ps1' -LookbackHours 720
```
It can be installed alongside the other collectors: it uses its own config, state, log and task.
Re-run the installer to change settings or update the script. It also installs
`BackupDashboard.Collector.psm1`, the code shared by all collectors, next to the script.
## What it sends
| Dashboard | Windows Server Backup |
|---|---|
| Company | `Tenant` from the config |
| Job | One per server, ID `wsb:<computer name>`, so changing `JobName` renames rather than duplicates it |
| Run | One per backup job from `Get-WBJob`, identified by its start time (WSB jobs have no ID). Restores are ignored. `details.items` lists what was backed up: the VMs for a Hyper-V backup, otherwise volumes |
| Status | Error HRESULT → **Failed**. Success, but a detailed error or a failed item (e.g. one volume) → **Warning**. Cancelled or unrecognised → **Warning**. In progress → **Running** |
| Started / finished | Job start / end time. WSB gives these as local-time text to the minute, in the server's date format; the collector parses them with that format |
| Next scheduled | `Get-WBSummary`'s `NextBackupTime`. Empty when there's no scheduled backup |
| Message | Error description, error codes (as `0x8XXXXXXX`), failed VMs/volumes, and the failure log path |
| Size | Bytes processed across the backup's items |
Each run covers the last 48 hours (`LookbackHours`). Runs already delivered are recorded in
`wsb-collector.state.json`.
## Troubleshooting
- Log: `C:\ProgramData\BackupDashboard\wsb-collector.log`. Exit codes: `0` OK, `1` some runs
failed to send (retried next run), `2` setup failed (config or WindowsServerBackup module).
- Wrong status, times or messages: run `.\Get-WsbCollectorDiagnostics.ps1` (read-only). It prints
the real structure of what the cmdlets return, plus recent `Microsoft-Windows-Backup` events to
compare times against.
## Testing without Windows Server Backup
```powershell
. ./tests/MockWsb.ps1 # or: . ./tests/MockWsb.ps1 -WithRunningJob
./Send-WsbBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
```
+302
View File
@@ -0,0 +1,302 @@
<#
.SYNOPSIS
Reports Windows Server Backup (WSB) results to the Backup Dashboard.
.DESCRIPTION
Runs on a server with the Windows Server Backup feature, normally as a SYSTEM scheduled task
every 60 minutes (see Install-WsbCollector.ps1). The server appears on the dashboard as one job.
Each run sends:
- the next scheduled backup (Get-WBSummary's NextBackupTime), which drives the dashboard's
Overdue flag, and
- every backup from the last LookbackHours that the dashboard hasn't already received in its
final state. A backup in progress is sent as Running until it finishes.
WSB jobs have no ID, so a run is identified by its start time. Already-reported runs are
tracked in a state file, and the ingest API is idempotent, so a failed or repeated run is
harmless: anything not confirmed is retried next time.
Written for Windows PowerShell 5.1, which the WindowsServerBackup module targets.
.PARAMETER ConfigPath
JSON config with ApiUrl, ApiKey and Tenant (optional: JobName, SourceSystem, LookbackHours).
.PARAMETER LookbackHours
Overrides the config value. Use 720 once to backfill 30 days of history.
.PARAMETER DryRun
Print the payloads instead of sending them. Doesn't touch the state or log files.
.EXAMPLE
.\Send-WsbBackupStatus.ps1 -DryRun
.EXAMPLE
.\Send-WsbBackupStatus.ps1 -LookbackHours 720
#>
[CmdletBinding()]
param(
[string]$ConfigPath = (Join-Path $env:ProgramData 'BackupDashboard\wsb-collector.json'),
[int]$LookbackHours = 0,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
# Shared collector code: installed next to this script; in the repo, in ..\common
$sharedModule = @(
(Join-Path $PSScriptRoot 'BackupDashboard.Collector.psm1'),
(Join-Path (Join-Path (Join-Path $PSScriptRoot '..') 'common') 'BackupDashboard.Collector.psm1')
) | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $sharedModule) {
Write-Host "BackupDashboard.Collector.psm1 not found next to $PSCommandPath; re-run the installer."
exit 2
}
Import-Module $sharedModule -Force
# Get-WBJob -Previous accepts at most 100. That covers a 30-day backfill at up to 3 backups a day;
# servers backing up more often only get their latest 100 runs backfilled.
$MaxPreviousJobs = 100
# ---------- Helpers ----------
function Import-Wsb {
if (Get-Command Get-WBJob -ErrorAction SilentlyContinue) { return } # already loaded
# Present only when the Windows Server Backup feature (with its command-line tools) is installed
Import-Module WindowsServerBackup
}
# ---------- Jobs ----------
# WBJob times are strings in local time, to the minute, formatted with the current culture
# ("10/6/2026 9:00 PM" on en-US). Parse with that same culture: PowerShell's [datetime] cast would
# use the invariant (US) format and misread dd/MM dates. Empty while not set (e.g. EndTime of a
# running backup).
function Get-JobTime($Job, [string]$Field) {
$value = Get-FirstValue $Job @($Field)
if ($null -eq $value) { return $null }
if ($value -is [datetime]) {
$dt = $value
} else {
$dt = [datetime]::MinValue
if (-not [datetime]::TryParse([string]$value, [Globalization.CultureInfo]::CurrentCulture,
[Globalization.DateTimeStyles]::AssumeLocal, [ref]$dt)) {
Write-Log "Couldn't parse $Field '$value'" 'WARN'
return $null
}
}
if ($dt.Year -le 1900) { return $null }
return $dt
}
# HRESULTs as numbers; 0 = OK. $null when the object doesn't have the property. WSB returns them
# as strings ("0"); PowerShell's conversion also accepts hex ("0x80042302") and negative decimals.
function Get-HResult($Object, [string]$Field) {
$value = Get-FirstValue $Object @($Field)
if ($null -eq $value) { return $null }
try { return [long]$value } catch { return $null }
}
function Format-HResult([long]$Value) {
if ($Value -lt 0) { $Value += 4294967296 } # show as the familiar unsigned 0x8XXXXXXX
return '0x{0:X8}' -f $Value
}
function Get-JobItems($Job) {
return @(Get-FirstValue $Job @('JobItems', 'Items') | Where-Object { $_ })
}
function Get-ItemName($Item) {
$name = Get-FirstValue $Item @('Name', 'VolumeLabel', 'MountPath', 'Path', 'Type')
if ($name) { return [string]$name }
return 'Item'
}
# The things actually backed up. Items can nest: a Hyper-V backup has one VirtualMachineList item
# whose SubItemList holds each VM (and "Host Component").
function Get-LeafItems($Items) {
foreach ($item in $Items) {
$subItems = @(Get-FirstValue $item @('SubItemList') | Where-Object { $_ })
if ($subItems.Count) { Get-LeafItems $subItems } else { $item }
}
}
function Test-ItemFailed($Item) {
$hr = Get-HResult $Item 'HResult'
$state = [string](Get-FirstValue $Item @('State', 'JobState'))
return ($hr -and $hr -ne 0) -or $state -match 'Fail'
}
# Items that reported an error: failed leaves, or a parent that counts failed sub-items without
# saying which
function Get-FailedItems($Job) {
$items = Get-JobItems $Job
$failed = @(Get-LeafItems $items | Where-Object { Test-ItemFailed $_ })
if ($failed.Count) { return $failed }
return @($items | Where-Object {
(Test-ItemFailed $_) -or ((Get-FirstValue $_ @('SubItemFailed')) -and [long](Get-FirstValue $_ @('SubItemFailed')) -gt 0)
})
}
# Bytes processed, from the top-level items (sub-items are already counted in their parent)
function Get-BytesProcessed($Job) {
$sum = [long]0
foreach ($item in Get-JobItems $Job) {
$value = Get-FirstValue $item @('BytesProcessed')
if ($value) { $sum += [long]$value }
}
return $sum
}
function Get-RunStatus($Job) {
$state = [string](Get-FirstValue $Job @('JobState', 'State'))
if (-not (Get-JobTime $Job 'EndTime') -and $state -notmatch 'Complet|Fail|Cancel|Abort|Stop|Succe') {
return 'running'
}
if ($state -match 'Fail') { return 'failed' }
if ($state -match 'Cancel|Abort|Stop') { return 'warning' }
$hr = Get-HResult $Job 'HResult'
if ($hr -and $hr -ne 0) { return 'failed' }
# Overall success but something underneath went wrong (e.g. a volume or files skipped)
$detailed = Get-HResult $Job 'DetailedHResult'
if (($detailed -and $detailed -ne 0) -or (Get-FailedItems $Job).Count) { return 'warning' }
if (-not $state -or $state -match 'Complet|Succe') { return 'success' }
return 'warning' # finished in a state we don't recognise: surface it rather than hide it
}
function Get-RunMessage($Job, [string]$Status) {
if ($Status -eq 'running') { return '' }
$lines = New-Object 'System.Collections.Generic.List[string]'
$state = [string](Get-FirstValue $Job @('JobState', 'State'))
$description = Get-FirstValue $Job @('ErrorDescription', 'DetailedMessage')
if ($description) { $lines.Add(([string]$description).Trim()) }
$hr = Get-HResult $Job 'HResult'
$detailed = Get-HResult $Job 'DetailedHResult'
if (($hr -and $hr -ne 0) -or ($detailed -and $detailed -ne 0)) {
$codes = @()
if ($hr -and $hr -ne 0) { $codes += "error $(Format-HResult $hr)" }
if ($detailed -and $detailed -ne 0) { $codes += "detailed error $(Format-HResult $detailed)" }
$lines.Add('Windows Server Backup reported ' + ($codes -join ', ') + '.')
}
foreach ($item in Get-FailedItems $Job) {
$itemError = Get-FirstValue $item @('ErrorDescription', 'DetailedMessage')
$itemHr = Get-HResult $item 'HResult'
$subFailed = Get-FirstValue $item @('SubItemFailed')
$line = "$(Get-ItemName $item) - " # names are often volumes like "D:", so no colon
if ($itemError) { $line += ([string]$itemError).Trim() }
elseif ($itemHr) { $line += "error $(Format-HResult $itemHr)" }
elseif ($subFailed -and [long]$subFailed -gt 0) { $line += "$subFailed sub-item(s) failed" }
else { $line += [string](Get-FirstValue $item @('State', 'JobState')) }
$lines.Add($line)
}
# WSB fills in FailureLogPath even for successful backups, so only point at it when relevant
if ($Status -ne 'success') {
$failureLog = Get-FirstValue $Job @('FailureLogPath')
if ($failureLog) { $lines.Add("Failure log: $failureLog") }
}
if ($lines.Count -eq 0) {
if ($Status -eq 'success') { return 'Backup completed successfully.' }
return "Backup finished with state '$state'."
}
# WSB reports "Completed" even for a backup that failed with an error code, so only mention
# the state when it says something else (e.g. cancelled)
if ($state -and $Status -ne 'success' -and $state -notmatch 'Complet') { $lines.Insert(0, "Job state: $state") }
return ($lines -join "`n")
}
function ConvertTo-Run($Job) {
$status = Get-RunStatus $Job
$details = [ordered]@{ server = $env:COMPUTERNAME }
$items = @(Get-LeafItems (Get-JobItems $Job) | ForEach-Object { Get-ItemName $_ })
if ($items.Count) { $details.items = $items }
$state = Get-FirstValue $Job @('JobState', 'State')
if ($state) { $details.wsb_state = [string]$state }
$version = Get-FirstValue $Job @('VersionId')
if ($version) { $details.version_id = [string]$version }
# No external_id: WSB jobs have no ID, so the API keys the run on started_at
$run = [ordered]@{
status = $status
started_at = ConvertTo-IsoTime (Get-JobTime $Job 'StartTime')
message = Get-RunMessage $Job $status
details = $details
}
if ($status -ne 'running') {
$finished = ConvertTo-IsoTime (Get-JobTime $Job 'EndTime')
if ($finished) { $run.finished_at = $finished } # otherwise the API uses receipt time
$bytes = Get-BytesProcessed $Job
if ($bytes -gt 0) { $run.size_bytes = $bytes }
}
return $run
}
function Test-IsBackupJob($Job) {
# Get-WBJob also lists recoveries; only backups belong on the dashboard
$type = [string](Get-FirstValue $Job @('JobType', 'Type'))
return (-not $type) -or $type -match 'Backup'
}
function Get-NextRun {
try {
$summary = Get-WBSummary
return ConvertTo-IsoTime (Get-FirstValue $summary @('NextBackupTime'))
} catch {
Write-Log "Couldn't read the backup summary: $(Get-ErrorText $_)" 'WARN'
return $null
}
}
# ---------- Main ----------
try {
$Config = Initialize-Collector -Name 'wsb' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Windows Server Backup' }
$JobName = if ($Config.JobName) { [string]$Config.JobName } else { "$env:COMPUTERNAME - Windows Server Backup" }
$LookbackHours = $Config.LookbackHours
Import-Wsb
$since = (Get-Date).AddHours(-$LookbackHours)
# Get-WBJob with no arguments returns the job in progress (if any); -Previous lists finished ones
$jobs = @(@(Get-WBJob) + @(Get-WBJob -Previous $MaxPreviousJobs) | Where-Object {
$_ -and (Test-IsBackupJob $_) -and (Get-JobTime $_ 'StartTime')
})
$byStart = [ordered]@{}
foreach ($job in $jobs) {
$start = Get-JobTime $job 'StartTime'
if ($start -lt $since) { continue }
$byStart[(ConvertTo-IsoTime $start)] = $job # a job in both lists: the finished entry (later) wins
}
$pending = @($byStart.Keys | Sort-Object | Where-Object { -not (Test-RunSent ($_)) } |
ForEach-Object { $byStart[$_] })
$jobInfo = [ordered]@{
# Stable per server, so renaming the dashboard job via JobName doesn't create a new one
external_id = "wsb:$($env:COMPUTERNAME.ToLowerInvariant())"
name = $JobName
# Always sent (null when unscheduled) so the dashboard's value tracks WSB's
next_run_at = Get-NextRun
}
} catch {
Write-Log "Collector setup failed: $(Get-ErrorText $_)" 'ERROR'
exit 2
}
$failures = 0
try {
if ($pending.Count -eq 0) {
# Keep next_run_at fresh even when nothing new ran
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo
}
foreach ($job in $pending) {
$run = ConvertTo-Run $job
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo -Run $run -SentKey $run.started_at -StartedAt ((Get-JobTime $job 'StartTime'))
}
} catch {
$failures++
Write-Log "Failed to report: $(Get-ErrorText $_)" 'ERROR'
}
$exitCode = Complete-Collector -Failures $failures
if ($exitCode) { exit $exitCode }
+99
View File
@@ -0,0 +1,99 @@
# Stand-ins for the WindowsServerBackup cmdlets. Object shapes follow Get-WsbCollectorDiagnostics.ps1
# output from a real Hyper-V host (Server 2019): job times are culture-formatted local-time strings
# to the minute ("10/6/2026 9:00 PM" on en-US), HRESULTs are strings, and a Hyper-V backup has one
# VirtualMachineList item whose SubItemList holds the VMs. When nothing is running, Get-WBJob
# returns an empty job (JobType None).
#
# . ./tests/MockWsb.ps1 # nothing running
# . ./tests/MockWsb.ps1 -WithRunningJob # a backup in progress
# ./Send-WsbBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
param([switch]$WithRunningJob)
# Fixed to the top of the hour so repeated runs see the same jobs
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
# Same format as the real cmdlet: short date + short time in the current culture
function Format-WbTime($Value) {
if ($null -eq $Value) { return '' }
return ([datetime]$Value).ToString('g', [Globalization.CultureInfo]::CurrentCulture)
}
function New-MockItem($Name, $Type = 'Volume', [long]$Bytes = 0, [string]$HResult = '0', $ErrorDescription = '', $SubItems = $null) {
$failedSubs = @($SubItems | Where-Object { $_ -and $_.HResult -ne '0' }).Count
[pscustomobject]@{
Name = $Name
Type = $Type
State = $(if ($HResult -ne '0') { 'Failed' } else { 'Completed' })
HResult = $HResult
DetailedHResult = '0'
ErrorDescription = $ErrorDescription
BytesProcessed = $Bytes
TotalBytes = $Bytes
CurrentItem = $null
SubItemProcessed = [long]@($SubItems | Where-Object { $_ }).Count
SubItemFailed = [long]$failedSubs
TotalSubItem = [long]@($SubItems | Where-Object { $_ }).Count
SubItemList = $SubItems
}
}
function New-VmList([long]$VmBytes, [string]$VmHResult = '0', $VmError = '') {
$vms = @(
(New-MockItem 'AAOG-EDM-AD1' 'Component' $VmBytes $VmHResult $VmError),
(New-MockItem 'Host Component' 'Component' 447729)
)
New-MockItem 'VirtualMachineList' 'VirtualmachineList' ($VmBytes + 447729) -SubItems $vms
}
function New-MockJob($Type, $Start, $End, $State, [string]$HResult = '0', [string]$DetailedHResult = '0', $ErrorDescription = '', $Items = $null) {
[pscustomobject]@{
JobType = $Type
StartTime = Format-WbTime $Start
EndTime = Format-WbTime $End
JobState = $State
CurrentOperation = ''
HResult = $HResult
DetailedHResult = $DetailedHResult
ErrorDescription = $ErrorDescription
JobItems = $Items
VersionId = $(if ($End) { $Start.ToUniversalTime().ToString('MM/dd/yyyy-HH:mm', [Globalization.CultureInfo]::InvariantCulture) } else { '' })
SuccessLogPath = 'C:\Windows\Logs\WindowsServerBackup\Backup.log'
# Populated even for successful backups, as on the real server
FailureLogPath = 'C:\Windows\Logs\WindowsServerBackup\Backup_Error.log'
}
}
$global:MockWsbRunning = if ($WithRunningJob) {
@(New-MockJob 'Backup' $now.AddMinutes(-10) $null 'Running' -Items @(New-VmList 10GB))
} else {
@(New-MockJob 'None' $null $null 'Unknown')
}
$global:MockWsbPrevious = @(
(New-MockJob 'Backup' $now.AddHours(-12) $now.AddHours(-11.6) 'Completed' -Items @(New-VmList 124756706036)),
# Completed overall, but one VM failed
(New-MockJob 'Backup' $now.AddHours(-36) $now.AddHours(-35.6) 'Completed' '0' '0' '' @(
New-VmList 0 '-2147024891' 'Access is denied.')),
# Failed outright: backup target not found (0x80042302)
(New-MockJob 'Backup' $now.AddHours(-30) $now.AddHours(-29.9) 'Completed' '-2147212542' '0' `
'The backup storage location could not be found.'),
# A volume-based backup, for servers that aren't Hyper-V hosts
(New-MockJob 'Backup' $now.AddHours(-20) $now.AddHours(-19.5) 'Completed' -Items @(
(New-MockItem 'C:' 'Volume' 40GB), (New-MockItem 'D:' 'Volume' 200GB))),
(New-MockJob 'Recovery' $now.AddHours(-5) $now.AddHours(-4) 'Completed'), # a restore: must be ignored
(New-MockJob 'Backup' $now.AddDays(-5) $now.AddDays(-5).AddHours(1) 'Completed') # outside 48h lookback
)
$global:MockWsbSummary = [pscustomobject]@{
NextBackupTime = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
NumberOfVersions = 1
LastSuccessfulBackupTime = $now.AddHours(-12)
LastBackupResultHR = [uint32]0
CurrentOperationStatus = $(if ($WithRunningJob) { 'BackupInProgress' } else { 'NoOperationInProgress' })
}
# Same limit as the real cmdlet
function Get-WBJob { param([ValidateRange(1, 100)][int]$Previous) if ($PSBoundParameters.ContainsKey('Previous')) { $global:MockWsbPrevious } else { $global:MockWsbRunning } }
function Get-WBSummary { $global:MockWsbSummary }
function Get-WBPolicy { [pscustomobject]@{ PolicyName = 'Scheduled' } }
function Get-WBSchedule { param($Policy) @($global:MockWsbSummary.NextBackupTime) }
+23
View File
@@ -0,0 +1,23 @@
[Unit]
Description=Backup Dashboard API
After=network.target
[Service]
User=backupdash
Group=backupdash
WorkingDirectory=/opt/backup-dashboard/backend
# Settings live in this file so manage.py reads exactly the same ones (see deploy/backup-dashboard.env)
EnvironmentFile=/etc/backup-dashboard/backup-dashboard.env
# Bound to localhost: only nginx can reach it, so nginx's auth can't be bypassed from outside.
# One worker is plenty at this scale and keeps SQLite writes simple.
ExecStart=/opt/backup-dashboard/venv/bin/uvicorn --factory app.main:create_app --host 127.0.0.1 --port 8000 --proxy-headers
Restart=on-failure
StateDirectory=backup-dashboard
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+4
View File
@@ -0,0 +1,4 @@
# Install as /etc/backup-dashboard/backup-dashboard.env
# Read by both the API service and manage.py, so they always use the same database.
BACKUP_DASH_DB=/var/lib/backup-dashboard/backup-dashboard.db
BACKUP_DASH_RETENTION_DAYS=90
+98
View File
@@ -0,0 +1,98 @@
server {
listen 80;
server_name backups.example.com;
# Session cookies and API keys must never cross plain HTTP
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name backups.example.com;
# Paths as created by `certbot certonly --nginx -d backups.example.com`
ssl_certificate /etc/letsencrypt/live/backups.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/backups.example.com/privkey.pem;
root /var/www/backup-dashboard/public;
index index.html;
# Strict CSP works because there is no inline JS/CSS
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000" always;
# ---------- Microsoft 365 sign-in ----------
# oauth2-proxy (deploy/oauth2-proxy.cfg) runs on 127.0.0.1:4180 and does the sign-in with
# Entra ID. Every request below asks it "is this browser signed in?" via auth_request.
# Its own pages: /oauth2/start (go to Microsoft), /oauth2/callback, /oauth2/sign_out
location /oauth2/ {
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# Where to return after signing in: the page that was asked for
proxy_set_header X-Auth-Request-Redirect $request_uri;
# Entra ID session cookies are large
proxy_buffer_size 16k;
proxy_buffers 8 16k;
}
# The sign-in check itself, only reachable as an auth_request subrequest
location = /oauth2/auth {
internal;
proxy_pass http://127.0.0.1:4180;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
proxy_buffer_size 16k;
proxy_buffers 8 16k;
}
# ---------- Dashboard ----------
location / {
auth_request /oauth2/auth;
# Not signed in: straight to Microsoft's sign-in, then back to this page
error_page 401 = /oauth2/start;
# Make browsers check for a newer page/script on every load (a cheap 304 when unchanged),
# so an update shows up at once. (expires, not add_header: an add_header here would drop
# the security headers above.)
expires -1;
try_files $uri $uri/ /index.html;
}
location /api/ {
auth_request /oauth2/auth;
# No redirect here: API calls from the page get a plain 401, and the page asks the user to
# reload, which goes through sign-in above.
# Who's signed in, for the dashboard's "signed in as" (proxy_set_header replaces any value
# the browser sent, so these can't be spoofed)
auth_request_set $auth_email $upstream_http_x_auth_request_email;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_name $upstream_http_x_auth_request_preferred_username;
proxy_set_header X-Forwarded-Email $auth_email;
proxy_set_header X-Forwarded-User $auth_user;
proxy_set_header X-Forwarded-Preferred-Username $auth_name;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app,
# so no Microsoft sign-in here
location = /api/v1/ingest {
client_max_body_size 256k;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+46
View File
@@ -0,0 +1,46 @@
# oauth2-proxy config for Backup Dashboard's Microsoft 365 (Entra ID) sign-in.
# Install as /etc/oauth2-proxy/backup-dashboard.cfg, readable only by root and the oauth2-proxy
# user (it holds the cookie secret). Needs oauth2-proxy 7.8 or newer for the entra-id provider.
# Replace the four <...> values; see the README's "Microsoft 365 sign-in" section.
## Where it listens: only nginx talks to it
http_address = "127.0.0.1:4180"
reverse_proxy = true
# Only nginx (on this machine) may supply X-Forwarded-* headers
trusted_proxy_ips = ["127.0.0.1/32", "::1/128"]
## Entra ID app registration (single tenant)
provider = "entra-id"
oidc_issuer_url = "https://login.microsoftonline.com/<TENANT_ID>/v2.0"
client_id = "<APPLICATION_CLIENT_ID>"
# The client secret's *value*, in a file of its own, root:oauth2-proxy, mode 640
client_secret_file = "/etc/oauth2-proxy/client-secret"
redirect_url = "https://<DASHBOARD_HOST>/oauth2/callback"
## Who may sign in
# Anyone in the tenant that Entra lets through. Limit it in Entra itself: Enterprise
# applications -> this app -> Properties -> "Assignment required" = Yes, then assign users or a
# group. Entra then refuses everyone else before they ever reach the dashboard.
email_domains = ["*"]
# The user's sign-in name (UPN), which every work account has. The "email" claim is only present
# when the account has a mailbox address.
oidc_email_claim = "preferred_username"
## Session cookie
# Generate with: openssl rand -base64 32 | tr -- '+/' '-_'
cookie_secret = "<COOKIE_SECRET>"
cookie_name = "_backup_dashboard"
cookie_secure = true
cookie_samesite = "lax"
# Sign in again (usually silently, via the Microsoft session) after this long
cookie_expire = "12h"
## Behaviour with nginx's auth_request
# Auth-only: nginx serves the dashboard; oauth2-proxy just answers "signed in or not"
upstreams = ["static://202"]
# Put the user in X-Auth-Request-* headers on /oauth2/auth responses, for nginx to pass on
set_xauthrequest = true
# Go straight to Microsoft instead of showing a "Sign in with..." page first
skip_provider_button = true
# Allow "Sign out" to continue to Microsoft's sign-out page
whitelist_domains = ["login.microsoftonline.com"]
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=oauth2-proxy (Microsoft 365 sign-in for Backup Dashboard)
After=network-online.target
Wants=network-online.target
[Service]
User=oauth2-proxy
Group=oauth2-proxy
ExecStart=/usr/local/bin/oauth2-proxy --config=/etc/oauth2-proxy/backup-dashboard.cfg
Restart=on-failure
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
-26
View File
@@ -1,26 +0,0 @@
version: '3.8'
services:
backend:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://dbadmin:Passw0rd123@postgres:5432/mydb
- PORT=3000
depends_on:
- postgres
postgres:
image: postgres:16
environment:
- POSTGRES_USER=dbadmin
- POSTGRES_PASSWORD=Passw0rd123!
- POSTGRES_DB=backupdb
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:
-21
View File
@@ -1,21 +0,0 @@
# Use the official Node.js image from the Docker Hub
FROM node:18-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy the package.json and package-lock.json to install dependencies
COPY package.json .
COPY package-lock.json .
# Install dependencies
RUN npm install
# Copy the rest of the app files
COPY . .
# Expose the port the app runs on
EXPOSE 3000
# Command to run the app
CMD ["node", "server.js"]
+347
View File
@@ -0,0 +1,347 @@
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--surface: #ffffff;
--surface-alt: #f0f2f5;
--border: #d9dde3;
--text: #1f2328;
--muted: #656d76;
--accent: #0969da;
/* Status palette: each has fg/bg/border so badges stay legible in both themes */
--success-fg: #116329; --success-bg: #dafbe1; --success-bd: #4ac26b;
--warning-fg: #7d4e00; --warning-bg: #fff8c5; --warning-bd: #d4a72c;
--failed-fg: #a40e26; --failed-bg: #ffebe9; --failed-bd: #ff8182;
--unknown-fg: #57606a; --unknown-bg: #eaeef2; --unknown-bd: #afb8c1;
--running-fg: #0550ae; --running-bg: #ddf4ff; --running-bd: #54aeff;
--radius: 8px;
--shadow: 0 1px 2px rgb(0 0 0 / 6%), 0 4px 12px rgb(0 0 0 / 4%);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
line-height: 1.45;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--surface: #161b22;
--surface-alt: #1c2128;
--border: #30363d;
--text: #e6edf3;
--muted: #8d96a0;
--accent: #4493f8;
--success-fg: #6fdd8b; --success-bg: #12261e; --success-bd: #2ea043;
--warning-fg: #f2cc60; --warning-bg: #272115; --warning-bd: #9e6a03;
--failed-fg: #ffa198; --failed-bg: #2d1214; --failed-bd: #da3633;
--unknown-fg: #9da7b3; --unknown-bg: #21262d; --unknown-bd: #484f58;
--running-fg: #79c0ff; --running-bg: #0c2d4b; --running-bd: #1f6feb;
--shadow: none;
}
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
}
.muted { color: var(--muted); }
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* ---------- Layout ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
padding: 0.75rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.topbar__title { margin: 0; font-size: 1.25rem; }
.topbar__meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.875rem; }
/* Only when shown: display:flex on the bare class would override the hidden attribute */
.topbar__user:not([hidden]) {
display: flex; align-items: center; gap: 0.75rem;
padding-left: 0.75rem; border-left: 1px solid var(--border);
}
a.btn { text-decoration: none; }
.container {
max-width: 1400px;
margin: 0 auto;
padding: 1.25rem 1.5rem 3rem;
}
/* ---------- Controls ---------- */
.btn {
font: inherit;
padding: 0.4rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-alt);
color: var(--text);
cursor: pointer;
}
.btn:hover { border-color: var(--muted); }
.btn:disabled { opacity: 0.6; cursor: progress; }
.btn--icon { padding: 0.25rem 0.55rem; line-height: 1; }
.input {
font: inherit;
width: min(100%, 360px);
padding: 0.45rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text);
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.toolbar { margin: 1rem 0; }
/* ---------- Banner ---------- */
.banner {
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
border: 1px solid;
}
.banner--error { background: var(--failed-bg); color: var(--failed-fg); border-color: var(--failed-bd); }
/* ---------- Summary cards ---------- */
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
}
.summary-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
padding: 0.9rem 1rem;
font: inherit;
text-align: left;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-left-width: 4px;
border-radius: var(--radius);
box-shadow: var(--shadow);
cursor: pointer;
}
.summary-card[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: -1px; }
.summary-card__count { font-size: 1.75rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.summary-card__label { font-size: 0.85rem; color: var(--muted); }
.summary-card--failed { border-left-color: var(--failed-bd); }
.summary-card--warning { border-left-color: var(--warning-bd); }
.summary-card--running { border-left-color: var(--running-bd); }
.summary-card--success { border-left-color: var(--success-bd); }
.summary-card--overdue { border-left-color: var(--unknown-bd); }
/* ---------- Table ---------- */
.table-wrap {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.jobs { width: 100%; border-collapse: collapse; }
.jobs th, .jobs td {
padding: 0.65rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.jobs tbody tr:last-child td { border-bottom: none; }
.jobs tbody tr:hover { background: var(--surface-alt); }
.jobs th { background: var(--surface-alt); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
.sort {
all: unset;
cursor: pointer;
color: var(--muted);
font-weight: 600;
}
.sort::after { content: " ↕"; opacity: 0.4; }
th[aria-sort="ascending"] .sort::after { content: " ↑"; opacity: 1; }
th[aria-sort="descending"] .sort::after { content: " ↓"; opacity: 1; }
th[aria-sort="ascending"] .sort,
th[aria-sort="descending"] .sort { color: var(--text); }
.time { display: flex; flex-direction: column; }
.time__rel { font-size: 0.8rem; color: var(--muted); }
.empty { padding: 2rem; text-align: center; color: var(--muted); margin: 0; }
/* ---------- Status badges ---------- */
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.65rem;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
border: 1px solid;
border-radius: 999px;
}
button.badge { cursor: pointer; }
button.badge:hover { filter: brightness(0.95); }
/* Icon + text, not colour alone, so it's readable for colour-blind users */
.badge__icon { font-size: 0.75rem; }
.badge--success { color: var(--success-fg); background: var(--success-bg); border-color: var(--success-bd); }
.badge--warning { color: var(--warning-fg); background: var(--warning-bg); border-color: var(--warning-bd); }
.badge--failed { color: var(--failed-fg); background: var(--failed-bg); border-color: var(--failed-bd); }
.badge--unknown { color: var(--unknown-fg); background: var(--unknown-bg); border-color: var(--unknown-bd); }
.badge--running { color: var(--running-fg); background: var(--running-bg); border-color: var(--running-bd); }
@media (prefers-reduced-motion: no-preference) {
.badge--running .badge__icon { animation: spin 2s linear infinite; }
}
@keyframes spin { to { transform: rotate(360deg); } }
.tag-overdue {
margin-left: 0.5rem;
padding: 0.05rem 0.45rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 4px;
color: var(--failed-fg);
background: var(--failed-bg);
}
/* ---------- Modal ---------- */
.modal {
width: min(720px, calc(100vw - 2rem));
max-height: calc(100vh - 4rem);
padding: 0;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
}
.modal::backdrop { background: rgb(0 0 0 / 45%); }
.modal__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.modal__title { margin: 0; font-size: 1.15rem; }
.modal__header p { margin: 0.15rem 0 0; }
.modal__body { padding: 1rem 1.25rem 1.25rem; }
.detail-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.4rem 1.25rem;
margin: 1rem 0;
}
.detail-grid dt { color: var(--muted); }
.detail-grid dd { margin: 0; overflow-wrap: anywhere; }
.detail-section-title { font-size: 0.85rem; text-transform: uppercase; color: var(--muted); margin: 1.25rem 0 0.5rem; }
.message {
margin: 0;
padding: 0.75rem 1rem;
max-height: 320px;
overflow: auto;
white-space: pre-wrap; /* preserve backup-software line breaks */
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.85rem;
background: var(--surface-alt);
border: 1px solid var(--border);
border-left-width: 4px;
border-radius: 6px;
}
.message--failed { border-left-color: var(--failed-bd); }
.message--warning { border-left-color: var(--warning-bd); }
.message--success { border-left-color: var(--success-bd); }
.message--running { border-left-color: var(--running-bd); }
.detail-status { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.link {
all: unset;
color: var(--accent);
cursor: pointer;
text-decoration: underline;
}
/* ---------- Run history ---------- */
.history-stats { margin: 0 0 0.6rem; font-size: 0.9rem; }
/* One cell per day, oldest → today. Colour + tooltip; the runs table below is the accessible detail. */
.day-strip {
display: grid;
grid-template-columns: repeat(30, 1fr);
gap: 3px;
}
.day {
height: 28px;
padding: 0;
border: 1px solid var(--border);
border-radius: 3px;
background: var(--surface-alt);
cursor: pointer;
}
.day:disabled { cursor: default; opacity: 0.6; }
.day:not(:disabled):hover { filter: brightness(0.9); }
.day[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: 1px; }
.day--success { background: var(--success-bd); border-color: var(--success-bd); }
.day--warning { background: var(--warning-bd); border-color: var(--warning-bd); }
.day--failed { background: var(--failed-bd); border-color: var(--failed-bd); }
.day--running { background: var(--running-bd); border-color: var(--running-bd); }
.day--unknown { background: var(--unknown-bd); border-color: var(--unknown-bd); }
.day-strip__ticks {
display: flex;
justify-content: space-between;
margin-top: 0.25rem;
font-size: 0.75rem;
}
.history-filter { margin: 0.75rem 0 0; font-size: 0.85rem; }
.runs-wrap {
margin-top: 0.75rem;
max-height: 300px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 6px;
}
.runs { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.runs th, .runs td {
padding: 0.4rem 0.75rem;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--border);
}
.runs th {
position: sticky;
top: 0;
background: var(--surface-alt);
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
}
.runs tbody tr:last-child td { border-bottom: none; }
.runs tr.is-selected td { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.runs .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; }
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backup Monitor</title>
<link rel="stylesheet" href="/css/styles.css">
<!-- type="module" is deferred by default; no inline JS so a strict CSP works -->
<script type="module" src="/js/app.js"></script>
</head>
<body>
<header class="topbar">
<h1 class="topbar__title">Backup Monitor</h1>
<div class="topbar__meta">
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
<button id="refresh-btn" class="btn" type="button">Refresh</button>
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
<span id="signed-in" class="topbar__user" hidden>
<span id="signed-in-email" class="muted"></span>
<a id="sign-out" class="btn" href="#">Sign out</a>
</span>
</div>
</header>
<main class="container">
<div id="error-banner" class="banner banner--error" role="alert" hidden></div>
<!-- Summary cards double as status filters -->
<section class="summary" aria-label="Status summary and filters">
<button type="button" class="summary-card" data-filter="all" aria-pressed="true">
<span class="summary-card__count" data-count="all">–</span>
<span class="summary-card__label">All jobs</span>
</button>
<button type="button" class="summary-card summary-card--failed" data-filter="failed" aria-pressed="false">
<span class="summary-card__count" data-count="failed">–</span>
<span class="summary-card__label">Failed</span>
</button>
<button type="button" class="summary-card summary-card--warning" data-filter="warning" aria-pressed="false">
<span class="summary-card__count" data-count="warning">–</span>
<span class="summary-card__label">Warning</span>
</button>
<button type="button" class="summary-card summary-card--running" data-filter="running" aria-pressed="false">
<span class="summary-card__count" data-count="running">–</span>
<span class="summary-card__label">Running</span>
</button>
<button type="button" class="summary-card summary-card--success" data-filter="success" aria-pressed="false">
<span class="summary-card__count" data-count="success">–</span>
<span class="summary-card__label">Success</span>
</button>
<button type="button" class="summary-card summary-card--overdue" data-filter="overdue" aria-pressed="false">
<span class="summary-card__count" data-count="overdue">–</span>
<span class="summary-card__label">Overdue</span>
</button>
</section>
<div class="toolbar">
<label for="search" class="visually-hidden">Search jobs</label>
<input id="search" class="input" type="search" placeholder="Search company or job…" autocomplete="off">
</div>
<div class="table-wrap">
<table class="jobs">
<thead>
<tr>
<th scope="col" aria-sort="none"><button type="button" class="sort" data-sort="company">Company</button></th>
<th scope="col" aria-sort="none"><button type="button" class="sort" data-sort="name">Backup Job</button></th>
<th scope="col" aria-sort="ascending"><button type="button" class="sort" data-sort="status">Status</button></th>
<th scope="col" aria-sort="none"><button type="button" class="sort" data-sort="lastRun">Last Run</button></th>
<th scope="col" aria-sort="none"><button type="button" class="sort" data-sort="nextRun">Next Scheduled</button></th>
</tr>
</thead>
<tbody id="jobs-body"></tbody>
</table>
<p id="empty-state" class="empty" hidden>No backup jobs match the current filter.</p>
</div>
</main>
<!-- Native <dialog>: built-in focus trap, Esc-to-close, and top-layer rendering -->
<dialog id="detail-dialog" class="modal" aria-labelledby="detail-title">
<div class="modal__header">
<div>
<h2 id="detail-title" class="modal__title"></h2>
<p id="detail-subtitle" class="muted"></p>
</div>
<button type="button" class="btn btn--icon" data-close aria-label="Close">✕</button>
</div>
<div id="detail-body" class="modal__body"></div>
</dialog>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
import { CONFIG } from './config.js';
export class ApiError extends Error {
constructor(message, status = null) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
async function request(path) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
try {
const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin', // ready for cookie/session auth behind nginx
signal: controller.signal,
});
// 401 means the nginx/SSO session lapsed; a reload sends the user back through sign-in
if (res.status === 401) throw new ApiError('Your session has expired. Reload the page to sign in again', 401);
if (!res.ok) throw new ApiError(`Server returned HTTP ${res.status}`, res.status);
return await res.json();
} catch (err) {
if (err.name === 'AbortError') throw new ApiError('Request timed out');
if (err instanceof ApiError) throw err;
throw new ApiError(`Network error: ${err.message}`);
} finally {
clearTimeout(timer);
}
}
const httpApi = {
/** GET /jobs → { generated_at, jobs: Job[] } */
async listJobs() {
const data = await request('/jobs');
if (!Array.isArray(data?.jobs)) throw new ApiError('Unexpected response shape from /jobs');
return data;
},
/** GET /jobs/:id → JobDetail */
getJob(id) {
return request(`/jobs/${encodeURIComponent(id)}`);
},
/** GET /jobs/:id/runs?days=N → { job_id, since, runs: RunSummary[] } (newest first, no messages) */
listRuns(id, days) {
return request(`/jobs/${encodeURIComponent(id)}/runs?days=${encodeURIComponent(days)}`);
},
/** GET /runs/:id → Run (with message and details) */
getRun(id) {
return request(`/runs/${encodeURIComponent(id)}`);
},
/** GET /me → { email, name } of the Microsoft 365 user, both null without sign-in */
getMe() {
return request('/me');
},
};
// Mock is loaded lazily so it never ships in the production request path.
export async function getApi() {
if (CONFIG.useMock) {
const { mockApi } = await import('./mock.js');
return mockApi;
}
return httpApi;
}
+546
View File
@@ -0,0 +1,546 @@
import { CONFIG } from './config.js';
import { getApi } from './api.js';
import {
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
} from './format.js';
const api = await getApi();
// ---------- Domain ----------
// rank drives default sort: worst first
const STATUS = {
failed: { label: 'Failed', icon: '✕', rank: 0 },
warning: { label: 'Warning', icon: '!', rank: 1 },
unknown: { label: 'Unknown', icon: '?', rank: 2 },
running: { label: 'Running', icon: '↻', rank: 3 },
success: { label: 'Success', icon: '✓', rank: 4 },
};
// Anything the backend sends that we don't recognise becomes "unknown" rather than breaking the UI
function normalizeStatus(raw) {
const s = String(raw ?? '').toLowerCase();
return Object.hasOwn(STATUS, s) ? s : 'unknown';
}
function normalizeJob(j) {
return {
id: String(j.id),
company: j.company_name ?? '',
name: j.job_name ?? '',
status: normalizeStatus(j.status),
message: j.message ?? '',
runningSince: parseDate(j.running_since),
// When the most recent run *finished*; a job that's running keeps showing its previous finish
lastRun: parseDate(j.last_run_at),
nextRun: parseDate(j.next_run_at),
};
}
// A running job isn't silently dead, so it's never overdue (a long run can pass its next slot)
const isOverdue = (job, now = Date.now()) =>
job.status !== 'running' && !!job.nextRun && job.nextRun.getTime() + CONFIG.overdueGraceMinutes * 60_000 < now;
// ---------- State ----------
const state = {
jobs: [],
filter: 'all',
search: '',
sort: { key: 'status', dir: 1 },
lastSuccess: null,
};
// ---------- DOM ----------
const $ = (sel) => document.querySelector(sel);
const dom = {
tbody: $('#jobs-body'),
empty: $('#empty-state'),
search: $('#search'),
refreshBtn: $('#refresh-btn'),
signedIn: $('#signed-in'),
signedInEmail: $('#signed-in-email'),
signOut: $('#sign-out'),
lastUpdated: $('#last-updated'),
errorBanner: $('#error-banner'),
dialog: $('#detail-dialog'),
detailTitle: $('#detail-title'),
detailSubtitle: $('#detail-subtitle'),
detailBody: $('#detail-body'),
};
// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML.
function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (v == null || v === false) continue;
if (k === 'class') node.className = v;
else if (k === 'dataset') Object.assign(node.dataset, v);
else node.setAttribute(k, v === true ? '' : v);
}
node.append(...children.flat().filter((c) => c != null && c !== false));
return node;
}
// ---------- Rendering ----------
function statusBadge(status, { asButton = false, opensDialog = false, dataset = null, title = null } = {}) {
const { label, icon } = STATUS[status];
const content = [el('span', { class: 'badge__icon', 'aria-hidden': 'true' }, icon), label];
return asButton
? el('button', {
type: 'button',
class: `badge badge--${status}`,
'aria-haspopup': opensDialog ? 'dialog' : null,
'aria-label': `${label} – view details`,
title,
dataset,
}, content)
: el('span', { class: `badge badge--${status}`, title }, content);
}
const runningTitle = (since) => (since ? `Running since ${formatDateTime(since)}` : null);
function timeCell(date, { extra = null, emptyLabel = 'Never' } = {}) {
if (!date) return el('td', {}, el('span', { class: 'muted' }, emptyLabel));
return el('td', {},
el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() },
el('span', {}, formatDateTime(date), extra),
el('span', { class: 'time__rel' }, formatRelative(date))));
}
function renderRow(job) {
const overdueTag = isOverdue(job) ? el('span', { class: 'tag-overdue' }, 'Overdue') : null;
return el('tr', {},
el('td', {}, job.company),
el('td', {}, job.name),
el('td', {}, statusBadge(job.status, {
asButton: true, opensDialog: true, dataset: { jobId: job.id }, title: runningTitle(job.runningSince),
})),
timeCell(job.lastRun),
// 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 });
function cmpDate(a, b) {
if (!a && !b) return 0;
if (!a) return 1;
if (!b) return -1;
return a - b;
}
const SORTERS = {
company: (a, b) => collator.compare(a.company, b.company),
name: (a, b) => collator.compare(a.name, b.name),
status: (a, b) => STATUS[a.status].rank - STATUS[b.status].rank,
lastRun: (a, b) => cmpDate(a.lastRun, b.lastRun),
nextRun: (a, b) => cmpDate(a.nextRun, b.nextRun),
};
function visibleJobs() {
const q = state.search.trim().toLowerCase();
const now = Date.now();
const { key, dir } = state.sort;
return state.jobs
.filter((j) => {
if (state.filter === 'overdue') return isOverdue(j, now);
return state.filter === 'all' || j.status === state.filter;
})
.filter((j) => !q || j.company.toLowerCase().includes(q) || j.name.toLowerCase().includes(q))
// Stable tie-breakers keep row order from jumping around on each refresh
.sort((a, b) => SORTERS[key](a, b) * dir || SORTERS.company(a, b) || SORTERS.name(a, b));
}
function renderSummary() {
const now = Date.now();
const counts = { all: state.jobs.length, failed: 0, warning: 0, running: 0, success: 0, overdue: 0 };
for (const j of state.jobs) {
if (j.status in counts) counts[j.status]++;
if (isOverdue(j, now)) counts.overdue++;
}
for (const [k, v] of Object.entries(counts)) {
const node = document.querySelector(`[data-count="${k}"]`);
if (node) node.textContent = v;
}
document.querySelectorAll('.summary-card').forEach((card) => {
card.setAttribute('aria-pressed', String(card.dataset.filter === state.filter));
});
}
function renderSortHeaders() {
document.querySelectorAll('th[aria-sort]').forEach((th) => {
const key = th.querySelector('.sort').dataset.sort;
th.setAttribute('aria-sort',
key === state.sort.key ? (state.sort.dir === 1 ? 'ascending' : 'descending') : 'none');
});
}
function render() {
const rows = visibleJobs();
dom.tbody.replaceChildren(...rows.map(renderRow));
dom.empty.hidden = rows.length > 0;
renderSummary();
renderSortHeaders();
}
// ---------- Data loading ----------
let inFlight = null;
let pollTimer = null;
function refresh() {
if (inFlight) return inFlight; // de-dupe overlapping manual + scheduled refreshes
dom.refreshBtn.disabled = true;
inFlight = (async () => {
try {
const data = await api.listJobs();
state.jobs = data.jobs.map(normalizeJob);
state.lastSuccess = new Date();
dom.errorBanner.hidden = true;
dom.lastUpdated.textContent = `Updated ${formatTime(state.lastSuccess)}`;
} catch (err) {
// Keep showing stale data, but make it obvious it's stale
dom.errorBanner.textContent = state.lastSuccess
? `Couldn't refresh (${err.message}). Showing data from ${formatTime(state.lastSuccess)}.`
: `Couldn't load backup status (${err.message}).`;
dom.errorBanner.hidden = false;
dom.lastUpdated.textContent = 'Update failed';
console.error(err);
} finally {
render();
dom.refreshBtn.disabled = false;
inFlight = null;
}
})();
return inFlight;
}
function schedulePoll() {
clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
await refresh();
schedulePoll();
}, CONFIG.refreshIntervalMs);
}
// Don't poll from background tabs (e.g. a wall display that's been switched away); catch up on return
document.addEventListener('visibilitychange', () => {
if (document.hidden) clearTimeout(pollTimer);
else refresh().then(schedulePoll);
});
// ---------- Detail modal ----------
// Every async result checks reqId first, so a slow response for job A can never paint over job B.
const modal = {
reqId: 0,
job: null,
detail: null,
runs: null, // null = loading, [] = none
runsError: null,
dayFilter: null, // local YYYY-MM-DD, or null for all days
selectedRunId: null, // null = the job's latest finished run (from the detail call)
runMessage: null, // { status, text, startedAt } for the selected run
runMessageError: null,
};
// Colour of a day cell: the worst thing that happened that day
const DAY_SEVERITY = ['failed', 'warning', 'unknown', 'running', 'success'];
const pad2 = (n) => String(n).padStart(2, '0');
const localDayKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
const dayLabelFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
const dayTickFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
function normalizeRun(r) {
return {
id: String(r.id),
status: normalizeStatus(r.status),
startedAt: parseDate(r.started_at),
finishedAt: parseDate(r.finished_at),
duration: r.duration_seconds,
size: r.size_bytes,
};
}
function buildDays(runs, count) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const days = [];
for (let i = count - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
days.push({ key: localDayKey(date), date, counts: {}, total: 0 });
}
const byKey = new Map(days.map((d) => [d.key, d]));
for (const run of runs) {
const day = byKey.get(localDayKey(run.startedAt));
if (!day) continue;
day.counts[run.status] = (day.counts[run.status] ?? 0) + 1;
day.total++;
}
for (const day of days) day.status = day.total ? DAY_SEVERITY.find((s) => day.counts[s]) : 'none';
return days;
}
function describeCounts(counts) {
return DAY_SEVERITY.filter((s) => counts[s])
.map((s) => `${counts[s]} ${STATUS[s].label.toLowerCase()}`)
.join(', ');
}
function detailRow(label, value) {
if (value == null || value === '') return [];
return [el('dt', {}, label), el('dd', {}, value)];
}
function renderSummarySection() {
const { job, detail } = modal;
const status = detail ? normalizeStatus(detail.status) : job.status;
const runningSince = detail ? parseDate(detail.running_since) : job.runningSince;
const lastStatus = detail?.last_status ? normalizeStatus(detail.last_status) : null;
dom.detailTitle.textContent = detail?.job_name ?? job.name;
dom.detailSubtitle.textContent = detail?.company_name ?? job.company;
return el('section', {},
el('div', { class: 'detail-status' },
statusBadge(status),
runningSince ? el('span', { class: 'muted' }, `since ${formatDateTime(runningSince)}`) : null),
el('dl', { class: 'detail-grid' },
// 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) : [],
detailRow('Last finished', formatDateTime(detail ? parseDate(detail.last_run_at) : job.lastRun)),
detailRow('Next scheduled', formatDateTime(detail ? parseDate(detail.next_run_at) : job.nextRun, 'Not scheduled')),
detailRow('Duration', formatDuration(detail?.duration_seconds)),
detailRow('Size', formatBytes(detail?.size_bytes)),
detailRow('Source', detail?.source_system)));
}
function renderHistorySection() {
const section = el('section', { id: 'detail-history' },
el('h3', { class: 'detail-section-title' }, `Last ${CONFIG.historyDays} days`));
if (modal.runsError) {
section.append(el('p', { class: 'banner banner--error' }, `Couldn't load run history: ${modal.runsError}`));
return section;
}
if (!modal.runs) {
section.append(el('p', { class: 'muted' }, 'Loading history…'));
return section;
}
const days = buildDays(modal.runs, CONFIG.historyDays);
const finished = modal.runs.filter((r) => r.finishedAt);
const ok = finished.filter((r) => r.status === 'success').length;
const totals = {};
for (const r of modal.runs) totals[r.status] = (totals[r.status] ?? 0) + 1;
section.append(
el('p', { class: 'history-stats' },
modal.runs.length
? `${modal.runs.length} runs · ${finished.length ? Math.round((ok / finished.length) * 100) : 0}% successful · ${describeCounts(totals)}`
: 'No runs recorded in this period.'),
el('div', { class: 'day-strip', role: 'group', 'aria-label': 'Daily results, oldest to newest. Select a day to filter runs.' },
days.map((d) => {
const label = `${dayLabelFmt.format(d.date)}: ${d.total ? describeCounts(d.counts) : 'no runs'}`;
return el('button', {
type: 'button',
class: `day day--${d.status}`,
title: label,
'aria-label': label,
'aria-pressed': String(modal.dayFilter === d.key),
disabled: !d.total,
dataset: { day: d.key },
});
})),
el('div', { class: 'day-strip__ticks muted', 'aria-hidden': 'true' },
el('span', {}, dayTickFmt.format(days[0].date)),
el('span', {}, 'Today')),
);
const rows = modal.dayFilter
? modal.runs.filter((r) => localDayKey(r.startedAt) === modal.dayFilter)
: modal.runs;
if (!rows.length) return section;
if (modal.dayFilter) {
const date = days.find((d) => d.key === modal.dayFilter)?.date;
section.append(el('p', { class: 'muted history-filter' },
`Showing ${dayLabelFmt.format(date)} · `,
el('button', { type: 'button', class: 'link', dataset: { day: modal.dayFilter } }, 'Show all days')));
}
section.append(el('div', { class: 'runs-wrap' },
el('table', { class: 'runs' },
el('thead', {}, el('tr', {},
['Status', 'Started', 'Finished', 'Duration', 'Size'].map((h) => el('th', { scope: 'col' }, h)))),
el('tbody', {}, rows.map((r) => el('tr', { class: r.id === modal.selectedRunId ? 'is-selected' : null },
el('td', {}, statusBadge(r.status, { asButton: true, dataset: { runId: r.id } })),
el('td', {}, formatDateTime(r.startedAt)),
el('td', {}, r.finishedAt ? formatDateTime(r.finishedAt) : el('span', { class: 'muted' }, 'In progress')),
el('td', {}, formatDuration(r.duration) ?? '—'),
el('td', {}, formatBytes(r.size) ?? '—')))))));
return section;
}
function renderMessageSection() {
const { detail, job } = modal;
let status;
let text;
let heading = 'Message';
if (modal.selectedRunId) {
const run = modal.runs?.find((r) => r.id === modal.selectedRunId);
heading = `Message · run started ${formatDateTime(run?.startedAt)}`;
status = run?.status ?? 'unknown';
text = modal.runMessageError
? `Couldn't load this run: ${modal.runMessageError}`
: modal.runMessage?.text ?? 'Loading…';
} else {
status = detail?.last_status ? normalizeStatus(detail.last_status) : job.status;
text = detail?.message ?? job.message;
}
const extras = !modal.selectedRunId && detail?.details && typeof detail.details === 'object'
? Object.entries(detail.details).flatMap(([k, v]) =>
detailRow(k.replace(/_/g, ' '), typeof v === 'object' ? JSON.stringify(v) : String(v)))
: modal.runMessage?.extras ?? [];
return el('section', { id: 'detail-message' },
el('h3', { class: 'detail-section-title' }, heading),
el('pre', { class: `message message--${status}` }, text || 'No message reported.'),
extras.length ? el('h3', { class: 'detail-section-title' }, 'Additional details') : null,
extras.length ? el('dl', { class: 'detail-grid' }, extras) : null);
}
function renderModal({ loading = false, error = null } = {}) {
dom.detailBody.replaceChildren(...[
renderSummarySection(),
loading ? el('p', { class: 'muted' }, 'Loading full details…') : null,
error ? el('p', { class: 'banner banner--error' }, `Couldn't load full details: ${error}`) : null,
renderMessageSection(),
renderHistorySection(),
].filter(Boolean));
}
// Re-render just the history section, keeping the runs table's scroll position
function renderHistory() {
const wrap = dom.detailBody.querySelector('.runs-wrap');
const scrollTop = wrap?.scrollTop ?? 0;
dom.detailBody.querySelector('#detail-history')?.replaceWith(renderHistorySection());
const newWrap = dom.detailBody.querySelector('.runs-wrap');
if (newWrap) newWrap.scrollTop = scrollTop;
}
function renderMessage() {
dom.detailBody.querySelector('#detail-message')?.replaceWith(renderMessageSection());
}
async function openDetail(jobId) {
const job = state.jobs.find((j) => j.id === jobId);
if (!job) return;
const reqId = ++modal.reqId;
Object.assign(modal, {
job, detail: null, runs: null, runsError: null, dayFilter: null,
selectedRunId: null, runMessage: null, runMessageError: null,
});
renderModal({ loading: true }); // show what we have immediately
if (!dom.dialog.open) dom.dialog.showModal();
const current = () => reqId === modal.reqId && dom.dialog.open;
const detailP = api.getJob(jobId).then(
(detail) => { if (current()) { modal.detail = detail; renderModal(); } },
(err) => { if (current()) renderModal({ error: err.message }); },
);
const runsP = api.listRuns(jobId, CONFIG.historyDays).then(
(data) => {
if (!current()) return;
modal.runs = (data.runs ?? []).map(normalizeRun).filter((r) => r.startedAt);
renderHistory();
},
(err) => { if (current()) { modal.runsError = err.message; renderHistory(); } },
);
await Promise.all([detailP, runsP]);
}
async function selectRun(runId) {
const reqId = modal.reqId;
modal.selectedRunId = runId;
modal.runMessage = null;
modal.runMessageError = null;
renderHistory();
renderMessage();
try {
const run = await api.getRun(runId);
if (reqId !== modal.reqId || modal.selectedRunId !== runId) return;
modal.runMessage = {
text: run.message,
extras: Object.entries(run.details ?? {}).flatMap(([k, v]) =>
detailRow(k.replace(/_/g, ' '), typeof v === 'object' ? JSON.stringify(v) : String(v))),
};
} catch (err) {
if (reqId !== modal.reqId || modal.selectedRunId !== runId) return;
modal.runMessageError = err.message;
}
renderMessage();
dom.detailBody.querySelector('#detail-message')?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
// ---------- Events ----------
dom.tbody.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-job-id]');
if (btn) openDetail(btn.dataset.jobId);
});
dom.dialog.addEventListener('click', (e) => {
// Click on the backdrop (the dialog element itself, outside its content) or the close button
if (e.target === dom.dialog || e.target.closest('[data-close]')) dom.dialog.close();
});
dom.detailBody.addEventListener('click', (e) => {
const day = e.target.closest('button[data-day]');
if (day) {
modal.dayFilter = modal.dayFilter === day.dataset.day ? null : day.dataset.day;
renderHistory();
return;
}
const run = e.target.closest('button[data-run-id]');
if (run) selectRun(run.dataset.runId);
});
document.querySelector('.summary').addEventListener('click', (e) => {
const card = e.target.closest('.summary-card');
if (!card) return;
state.filter = card.dataset.filter;
render();
});
document.querySelector('.jobs thead').addEventListener('click', (e) => {
const btn = e.target.closest('.sort');
if (!btn) return;
const key = btn.dataset.sort;
state.sort = state.sort.key === key ? { key, dir: -state.sort.dir } : { key, dir: 1 };
render();
});
dom.search.addEventListener('input', () => {
state.search = dom.search.value;
render();
});
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
// ---------- Signed-in user ----------
async function showSignedInUser() {
try {
const me = await api.getMe();
const who = me?.email || me?.name;
if (!who) return; // no Microsoft 365 sign-in in front of the dashboard (dev, mock)
dom.signedInEmail.textContent = who;
dom.signOut.href = CONFIG.signOutUrl;
dom.signedIn.hidden = false;
} catch (err) {
console.error(err); // not worth a banner: the jobs request will surface real problems
}
}
// ---------- Boot ----------
showSignedInUser();
refresh().then(schedulePoll);
+19
View File
@@ -0,0 +1,19 @@
// Central runtime config. Kept as a static file so ops can change it without a build.
const params = new URLSearchParams(window.location.search);
export const CONFIG = Object.freeze({
apiBaseUrl: '/api/v1',
refreshIntervalMs: 60_000,
requestTimeoutMs: 15_000,
// A job whose next scheduled run is this far in the past is flagged "Overdue".
// Catches the silent failure mode: a job stops reporting and stays green forever.
overdueGraceMinutes: 30,
// Run history shown in the job modal (the API caps this at 30)
historyDays: 30,
// ?mock=1 → use in-browser sample data, no backend needed
useMock: params.has('mock'),
// oauth2-proxy clears the dashboard session, then hands over to Microsoft's sign-out page.
// Signing out of the dashboard alone would be undone at once: the browser's Microsoft session
// would sign it straight back in.
signOutUrl: '/oauth2/sign_out?rd=' + encodeURIComponent('https://login.microsoftonline.com/common/oauth2/v2.0/logout'),
});
+44
View File
@@ -0,0 +1,44 @@
const dateTimeFmt = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
const timeFmt = new Intl.DateTimeFormat(undefined, { timeStyle: 'medium' });
const relFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const REL_UNITS = [
['year', 31_536_000], ['month', 2_592_000], ['week', 604_800],
['day', 86_400], ['hour', 3_600], ['minute', 60],
];
export function parseDate(value) {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
export const formatDateTime = (d, emptyLabel = '—') => (d ? dateTimeFmt.format(d) : emptyLabel);
export const formatTime = (d) => timeFmt.format(d);
export function formatRelative(d, now = Date.now()) {
if (!d) return '';
const diffSec = Math.round((d.getTime() - now) / 1000);
for (const [unit, secs] of REL_UNITS) {
if (Math.abs(diffSec) >= secs) return relFmt.format(Math.round(diffSec / secs), unit);
}
return relFmt.format(diffSec, 'second');
}
export function formatDuration(totalSec) {
if (totalSec == null || Number.isNaN(Number(totalSec))) return null;
const s = Math.max(0, Math.round(totalSec));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return [h && `${h}h`, (h || m) && `${m}m`, `${sec}s`].filter(Boolean).join(' ');
}
export function formatBytes(bytes) {
if (bytes == null || Number.isNaN(Number(bytes))) return null;
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let n = Number(bytes);
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
+132
View File
@@ -0,0 +1,132 @@
// Sample data matching the API contract. Timestamps are relative to page load so it always looks "live".
const now = Date.now();
const MIN = 60_000;
const HR = 60 * MIN;
const DAY = 24 * HR;
const iso = (offsetMs) => new Date(now + offsetMs).toISOString();
// every_h drives the generated run history; it isn't part of the API.
const JOBS = [
{
id: 1, company_name: 'Acme Corp', job_name: 'File Server – Nightly', status: 'success', every_h: 24,
last_run_at: iso(-6 * HR), next_run_at: iso(18 * HR),
message: 'Job completed successfully. 1,284 files processed.',
source_system: 'Veeam B&R', duration_seconds: 2840, size_bytes: 128_849_018_880,
},
{
id: 2, company_name: 'Acme Corp', job_name: 'SQL01 – Transaction Logs', status: 'warning', every_h: 1,
last_run_at: iso(-25 * MIN), next_run_at: iso(35 * MIN),
message: 'Processing SQL01 Warning:\nFailed to truncate transaction logs for database "ERP_PROD".\nLog backup completed but truncation was skipped.',
source_system: 'Veeam B&R', duration_seconds: 312, size_bytes: 2_147_483_648,
details: { retry_count: 0, repository: 'REPO-ONSITE-01' },
},
{
// Running now; "last run" is still the previous completion
id: 7, company_name: 'Acme Corp', job_name: 'Hyper-V Hosts – Daily', status: 'running', every_h: 24,
running_since: iso(-12 * MIN), last_status: 'success',
last_run_at: iso(-DAY + 40 * MIN), next_run_at: iso(DAY - 12 * MIN),
message: 'Completed successfully. 6 VMs processed.',
source_system: 'Veeam B&R', duration_seconds: 3120, size_bytes: 412_316_860_416,
},
{
id: 3, company_name: 'Bluewater Dental', job_name: 'Workstations – Daily', status: 'failed', every_h: 24,
last_run_at: iso(-3 * HR), next_run_at: iso(21 * HR),
message: 'Error: Unable to connect to backup repository \\\\NAS02\\Backups.\nThe network path was not found. (0x80070035)\nJob terminated after 3 retries.',
source_system: 'Datto', duration_seconds: 95,
details: { retry_count: 3, target: '\\\\NAS02\\Backups' },
},
{
id: 4, company_name: 'Bluewater Dental', job_name: 'Imaging Server – Hourly', status: 'success', every_h: 1,
last_run_at: iso(-50 * MIN), next_run_at: iso(10 * MIN),
message: 'Completed.', source_system: 'Datto', duration_seconds: 420, size_bytes: 8_589_934_592,
},
{
// Green status but next run is in the past → should show as Overdue
id: 5, company_name: 'Northfield Logistics', job_name: 'Exchange – Nightly', status: 'success', every_h: 24,
last_run_at: iso(-30 * HR), next_run_at: iso(-6 * HR),
message: 'Completed successfully.', source_system: 'Acronis', duration_seconds: 5400,
},
{
// Never run / unrecognised status → rendered as Unknown
id: 6, company_name: 'Northfield Logistics', job_name: 'New NAS – Weekly', status: null,
last_run_at: null, next_run_at: iso(2 * DAY),
message: '', source_system: 'Synology Hyper Backup',
},
];
const MESSAGES = {
success: 'Job completed successfully.',
warning: 'Completed with warnings: 1 file skipped (file in use).',
failed: 'Error: Target repository unreachable. Job terminated.',
};
// Deterministic pseudo-random so history doesn't reshuffle on every modal open
function rng(seed) {
let x = seed;
return () => ((x = (x * 1_103_515_245 + 12_345) % 2_147_483_648) / 2_147_483_648);
}
const RUNS = new Map(); // job id → runs, newest first
for (const job of JOBS) {
const runs = [];
if (job.running_since) {
runs.push({ id: `${job.id}-r`, status: 'running', started_at: job.running_since, finished_at: null, message: 'In progress…' });
}
if (job.last_run_at && job.every_h) {
const rand = rng(job.id * 7919);
const dur = (job.duration_seconds ?? 600) * 1000;
let end = Date.parse(job.last_run_at);
for (let i = 0; end - dur > now - 30 * DAY; i++, end -= job.every_h * HR) {
const r = rand();
const status = i === 0 ? (job.last_status ?? job.status) : r < 0.03 ? 'failed' : r < 0.1 ? 'warning' : 'success';
const thisDur = dur * (0.8 + rand() * 0.4);
runs.push({
id: `${job.id}-${i}`,
status,
started_at: new Date(end - thisDur).toISOString(),
finished_at: new Date(end).toISOString(),
duration_seconds: Math.round(thisDur / 1000),
size_bytes: status === 'failed' ? null : job.size_bytes,
message: i === 0 ? job.message : MESSAGES[status],
details: i === 0 ? job.details ?? {} : {},
});
}
}
RUNS.set(String(job.id), runs);
}
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
const summary = ({ details, every_h, last_status, duration_seconds, size_bytes, ...rest }) => rest;
export const mockApi = {
async listJobs() {
await delay(250);
return { generated_at: new Date().toISOString(), jobs: JOBS.map(summary) };
},
async getJob(id) {
await delay(200);
const job = JOBS.find((j) => String(j.id) === String(id));
if (!job) throw new Error('Job not found');
const { every_h, ...rest } = job;
return { last_status: job.status === 'running' ? null : job.status, ...rest, details: job.details ?? {} };
},
async listRuns(id, days) {
await delay(300);
const since = now - days * DAY;
const runs = (RUNS.get(String(id)) ?? [])
.filter((r) => Date.parse(r.started_at) >= since)
.map(({ message, details, ...r }) => r);
return { job_id: id, since: new Date(since).toISOString(), runs };
},
async getRun(id) {
await delay(150);
for (const runs of RUNS.values()) {
const run = runs.find((r) => r.id === id);
if (run) return { details: {}, ...run };
}
throw new Error('Run not found');
},
async getMe() {
return { email: null, name: null }; // no sign-in in mock mode
},
};