9 Commits
Author SHA1 Message Date
kyle dd49d85c77 Add LICENSE.md 2026-10-09 19:29:11 +00:00
kyleandClaude Opus 5.5 2297187f88 Add per-company backup reports (printable page and CSV)
/report.html shows one company's backups over the last 7, 30 or 90
days, chosen in the page and kept in the URL so reports can be
bookmarked or linked. Reached from Reports in the top bar, or a
company's name in a job's detail.

- Summary (jobs, runs, success rate, warnings, failures, overdue now);
  per job: run counts, success rate (successful / finished runs), last
  result and last successful backup; a day-by-day strip; every warning
  and failure with its message
- Print / Save as PDF: a print stylesheet prints light whatever the
  screen theme, hides the controls, keeps rows and strips whole across
  pages, and keeps the coloured cells and badges
- Days before a job was first known show as "Not monitored yet"
  rather than as missed backups, so a 90-day report for a newly added
  client doesn't look like 60 days of failures
- CSV of every run (full messages, times in the browser's time zone,
  UTF-8 BOM for Excel, formula-like values defused with a leading ')

Backend: GET /api/v1/tenants, /api/v1/reports/tenant and
/api/v1/reports/tenant.csv; periods capped at retention. tzdata added
for systems without time zone data. Frontend status and day-strip
helpers moved out of app.js into status.js / history.js for reuse.

Tested: 9 new backend tests; in the browser against the API with 30
days of demo history (summary, issues, strips, 7/30/90 switching,
CSV download with BOM, print styles applied on screen), mock mode and
phone width.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:56:54 -06:00
kyleandClaude Opus 5.5 abb684ff0d Document updating the dashboard server
The README only covered the first install, so the admin page update
stalled at a step that wasn't written down: nginx serves its own copy
of public/ in /var/www, and without copying it a new page quietly falls
back to index.html.

"Updating the server" covers: backing up the database, pulling and
listing what changed, Python packages, copying the web files,
restarting, health checks, which deploy/ templates map to which
installed files, and redeploying collectors when they change. A
rollback section restores the database before starting the older API
(which refuses a newer database), using sqlite3 .restore rather than
cp because of the WAL file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 14:24:51 -06:00
kyleandClaude Opus 5.5 d4e821799c Add an admin page for managing collector API keys
Admins (Microsoft 365 sign-in names in BACKUP_DASH_ADMINS) get an
Admin link to /admin.html, where they can create, list and revoke the
API keys collectors use, instead of running manage.py on the server.

Backend
- /api/v1/admin/keys (list, create, revoke), admins only (403
  otherwise); the user comes from the headers nginx sets after sign-in
- Changes need an X-Requested-By header, which another site's page
  can't add, so a signed-in admin's browser can't be used to forge them
- A new key is returned once; only its hash is stored. Names are unique
  for good, so revoked keys' history stays unambiguous
- Keys record who created/revoked them and the company they last
  reported for (schema v2; databases migrate automatically on start)
- /api/v1/me reports is_admin; manage.py shares the key code and
  records itself as creator/revoker

Frontend
- admin.html / admin.js: name validation shown after interaction
  (:user-invalid, aria-invalid kept in sync), the one-time key with a
  copy button (falls back to selecting it if clipboard access is
  refused), a key table flagging keys quiet for over 24 hours, and
  revoking confirmed in a <dialog>; results announced in a status region
- Shared el() and signed-in helpers moved to dom.js / signed-in.js
- Mock mode (?mock=1) includes sample keys

Tested: 15 new backend tests (access, forged requests, names,
duplicates, revoking, last-used company, v1 -> v2 upgrade); in the
browser, every admin flow in mock mode and at phone width; end to end
through the nginx config with the real API (a key created on the API
works for ingest; revoking it makes ingest 401; non-admins see an
explanation and no Admin link).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 13:52:29 -06:00
kyleandClaude Opus 5.5 c5fe69a813 Add a Pester test suite for the collectors
84 tests in collectors/tests, run with Invoke-CollectorTests.ps1 (~15 s,
PowerShell 7.2+ and Pester 5.5+):

- Module.Tests: the shared module (property lookup, time format,
  config/lookback, sent-run state and pruning, dry runs, exit codes,
  401 detection)
- Veeam/Mars/Wsb/Vb365.Tests: each collector run in its own process
  against its mock and a stand-in ingest API, asserting the behaviours
  confirmed on real servers: VBR 13 live schedule, copy jobs, MARS UTC
  times and ErrorCode 0, WSB culture-formatted times and nested VMs,
  VB365 organization mapping, log prefixes and retries, state and
  resending, rejected keys
- Installers.Tests: config, files and scheduled task from each
  installer, with stand-ins for icacls / Task Scheduler / the key prompt

Collectors run under the test runner's PowerShell, or another shell via
-CollectorShell (e.g. powershell.exe, to test under Windows PowerShell
5.1 like the MARS, WSB and VB365 servers). The mocks take their "now"
from MOCK_NOW so expectations can't drift with the clock; the WSB mock
gains -RunningJobFinished for the running-to-finished case.

Each of 12 past bugs, put back one at a time, fails the test for its
behaviour (and the runner exits 1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 13:36:27 -06:00
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
60 changed files with 4146 additions and 614 deletions
+17
View File
@@ -0,0 +1,17 @@
Backup Dashboard - A simple dashboad showing the status of backup jobs across multiple platforms
Copyright (C) 2026 Kyle Harder - Reliability Technology Soluctions Ltd.
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 <http://www.gnu.org/licenses/>.
Source: http://opensource.org/licenses/GPL-3.0
+182 -9
View File
@@ -5,14 +5,15 @@ Proof of concept backup dashboard, intended to display backup jobs from multiple
```
collectors ──POST /api/v1/ingest (API key)──▶ ┌──────────────┐
│ FastAPI + │
browser ──nginx (basic auth, TLS)──▶ /api/* ─▶│ SQLite │
└─ static files: public/ └──────────────┘
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/`: 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)).
- `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
@@ -66,18 +67,99 @@ curl -X POST https://backups.example.com/api/v1/ingest \
| `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/tenants` | Companies with jobs, for choosing a report |
| `GET /api/v1/reports/tenant?tenant=…&days=30` | One company's jobs and every run in the period (messages for warnings/failures only) |
| `GET /api/v1/reports/tenant.csv?tenant=…&days=30&tz=America/Edmonton` | The same as CSV, with full messages and times in `tz` |
| `GET /api/v1/health` | `{"status": "ok"}` |
The OpenAPI schema is at `/api/v1/openapi.json`.
## Reports
**Reports** in the dashboard's top bar (or a company's name in a job's detail) opens a backup report
for one company over the last 7, 30 or 90 days. The address carries the choice, e.g.
`/report.html?tenant=Alpha%20Construction&days=30`, so a report can be bookmarked or linked.
- **On the page:** a summary (jobs, runs, success rate, warnings, failures, overdue now); per job,
its run counts, success rate, last result and last successful backup; a day-by-day strip; and
every warning and failure with its message. **Print / Save as PDF** prints it light and without
the page controls, whatever the screen's theme.
- **CSV:** every run in the period, with full messages and times in the browser's time zone. It
opens directly in Excel. Values that a spreadsheet would treat as formulas are prefixed with `'`.
- **Success rate** is successful runs ÷ finished runs; runs still in progress don't count against it.
- **Days before a job was monitored** (before its earliest known run) show as *Not monitored
yet*, not as missed backups.
- Periods are limited by retention (`BACKUP_DASH_RETENTION_DAYS`, 90 by default). Anyone who can
sign in can view reports.
## Authentication
- **Dashboard users:** nginx basic auth over HTTPS (`deploy/nginx.conf`). Every user can see every tenant.
*Planned:* Microsoft 365 sign-in through [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/)
with the Entra ID provider and nginx `auth_request`. That's an nginx-only change. The frontend
already tells the user to reload when a session expires (HTTP 401).
- **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.
- **Admins:** the sign-in names in `BACKUP_DASH_ADMINS` (in `/etc/backup-dashboard/backup-dashboard.env`,
comma-separated) get an **Admin** link to the API keys page. Restart the API after changing it.
- **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.
### Managing API keys
On the **Admin** page (`/admin.html`), admins can:
- **Create** a key. It's shown once, with a copy button. Paste it when a collector's installer
asks for the API key.
- **See** each key's status, when it was last used and for which company, and who created it.
Active keys quiet for over 24 hours are flagged, since collectors report hourly.
- **Revoke** a key. The collectors using it are refused from their next run. This can't be undone,
and names stay taken, so the history stays unambiguous.
The same is possible from the server with `manage.py` (`create-key`, `list-keys`, `revoke-key`),
recorded as created/revoked by `manage.py (<user>)`.
### 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
@@ -91,12 +173,18 @@ BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload
# → http://localhost:8000
```
Collector tests (PowerShell 7 + Pester; see [collectors/tests](collectors/tests/README.md)):
```powershell
./collectors/tests/Invoke-CollectorTests.ps1
```
## 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 create users with `htpasswd -B`.
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
@@ -106,3 +194,88 @@ BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload
Back up the SQLite file with `sqlite3 backup-dashboard.db ".backup copy.db"`. Don't use a plain `cp`
while the service is running.
### Updating the server
After changes are merged into `main`, on the dashboard server:
1. **Back up the database.** It takes seconds, and an update may upgrade the database's structure
when the API restarts, which only this backup can reverse. Needs the `sqlite3` package:
```bash
sudo -u backupdash sqlite3 /var/lib/backup-dashboard/backup-dashboard.db ".backup /var/lib/backup-dashboard/before-update.db"
```
2. **Pull, and see what changed** (the list tells you whether steps 7 and 8 apply):
```bash
sudo git -C /opt/backup-dashboard pull
```
```bash
git -C /opt/backup-dashboard diff --stat ORIG_HEAD HEAD
```
3. **Python packages.** Does nothing unless `backend/requirements.txt` changed:
```bash
sudo /opt/backup-dashboard/venv/bin/pip install -q -r /opt/backup-dashboard/backend/requirements.txt
```
4. **Web files.** nginx serves its own copy in `/var/www`, so a pull alone doesn't change what
browsers get. If this step is skipped, new pages quietly show the dashboard instead (nginx
falls back to `index.html` for files it doesn't have):
```bash
sudo rsync -a /opt/backup-dashboard/public/ /var/www/backup-dashboard/public/
```
Browsers pick up the new files on their next page load.
5. **Restart the API**, which loads the new code and settings and upgrades the database if needed:
```bash
sudo systemctl restart backup-dashboard-api
```
6. **Check it's healthy.** The first should print `{"status":"ok"}`; the log shouldn't show errors:
```bash
curl -s http://127.0.0.1:8000/api/v1/health
```
```bash
journalctl -u backup-dashboard-api -n 20 --no-pager
```
7. **If files in `deploy/` changed:** those are templates whose installed copies live elsewhere,
so a pull doesn't update them. Compare each changed one with its installed copy (e.g.
`diff /opt/backup-dashboard/deploy/oauth2-proxy.service /etc/systemd/system/oauth2-proxy.service`)
and bring the changes across:
| Changed in the repo | Installed copy | After updating it |
|---|---|---|
| `deploy/backup-dashboard.env` | `/etc/backup-dashboard/backup-dashboard.env` | Add any new settings, keeping your values; restart the API |
| `deploy/backup-dashboard-api.service` | `/etc/systemd/system/backup-dashboard-api.service` | `sudo systemctl daemon-reload`, restart the API |
| `deploy/nginx.conf` | Your nginx site config | Keep your server name and certificate paths; `sudo nginx -t && sudo systemctl reload nginx` |
| `deploy/oauth2-proxy.cfg` | `/etc/oauth2-proxy/backup-dashboard.cfg` | Keep your IDs and secrets; `sudo systemctl restart oauth2-proxy` |
| `deploy/oauth2-proxy.service` | `/etc/systemd/system/oauth2-proxy.service` | `sudo systemctl daemon-reload && sudo systemctl restart oauth2-proxy` |
8. **If files in `collectors/` changed:** the backup servers keep running their installed copy.
On each affected server, copy over the whole `collectors` folder, re-run that collector's
installer, and do a dry run (see its README). The changes are already covered by the
collector tests (`./collectors/tests/Invoke-CollectorTests.ps1`) if you want to run them first.
**If something goes wrong**, go back to the version from before step 2 (`ORIG_HEAD`). The order
matters: an older API won't start on a database a newer one has upgraded, so restore the database
before starting it.
1. Stop the API:
```bash
sudo systemctl stop backup-dashboard-api
```
2. Check whether the update upgraded the database: if these two numbers differ, it did.
```bash
for db in backup-dashboard before-update; do sudo -u backupdash sqlite3 /var/lib/backup-dashboard/$db.db "PRAGMA user_version"; done
```
If so, restore the backup from step 1 (`.restore`, not `cp`: it also deals with SQLite's `-wal`
file). Runs reported since the backup drop out of the history, since collectors have already
marked them as sent; backups themselves are unaffected.
```bash
sudo -u backupdash sqlite3 /var/lib/backup-dashboard/backup-dashboard.db ".restore /var/lib/backup-dashboard/before-update.db"
```
3. Go back to the previous code and web files, then start the API:
```bash
sudo git -C /opt/backup-dashboard checkout ORIG_HEAD
```
```bash
sudo rsync -a /opt/backup-dashboard/public/ /var/www/backup-dashboard/public/ && sudo systemctl start backup-dashboard-api
```
Once the problem is fixed, return to `main` with `sudo git -C /opt/backup-dashboard checkout main`
and update as above.
+44
View File
@@ -0,0 +1,44 @@
"""Admin API: managing collector API keys. Only for users in BACKUP_DASH_ADMINS."""
import logging
import sqlite3
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, StringConstraints
from . import db, keys
from .auth import require_admin, require_page_request
log = logging.getLogger("backup_dashboard")
router = APIRouter(prefix="/api/v1/admin", dependencies=[Depends(require_admin)])
class NewKey(BaseModel):
name: Annotated[str, StringConstraints(strip_whitespace=True, pattern=f"^{keys.NAME_PATTERN}$")]
@router.get("/keys")
def list_keys(conn: sqlite3.Connection = Depends(db.get_db)):
return {"keys": keys.list_keys(conn)}
@router.post("/keys", status_code=201, dependencies=[Depends(require_page_request)])
def create_key(body: NewKey, admin: str = Depends(require_admin), conn: sqlite3.Connection = Depends(db.get_db)):
try:
record, secret = keys.create_key(conn, body.name, created_by=admin)
except keys.KeyNameTaken:
raise HTTPException(409, f"A key named {body.name!r} already exists (revoked keys keep their names)") from None
log.info("admin %s created API key %r", admin, body.name)
# The key itself is returned this once; only its hash is stored
return {"key": record, "secret": secret}
@router.post("/keys/{key_id}/revoke", dependencies=[Depends(require_page_request)])
def revoke_key(key_id: int, admin: str = Depends(require_admin), conn: sqlite3.Connection = Depends(db.get_db)):
record = keys.revoke_key(conn, key_id, revoked_by=admin)
if record is None:
raise HTTPException(404, "Key not found")
log.info("admin %s revoked API key %r", admin, record["name"])
return {"key": record}
+35 -4
View File
@@ -1,14 +1,16 @@
"""Collector (machine) authentication.
"""Authentication and authorisation.
Dashboard *users* are authenticated by nginx in front of the app (basic auth today,
oauth2-proxy + Microsoft Entra ID later), so nothing here deals with people.
Collectors (machines) authenticate with API keys, checked here. Dashboard *users* sign in with
Microsoft 365 in nginx (via oauth2-proxy), which then tells the app who they are in
X-Forwarded-* headers it sets itself (a browser can't supply them). The app only decides whether
that user is an admin.
"""
import hashlib
import secrets
import sqlite3
from fastapi import Depends, Header, HTTPException
from fastapi import Depends, Header, HTTPException, Request
from .db import get_db
from .timeutil import to_db, utcnow
@@ -25,6 +27,35 @@ def hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def current_user(request: Request) -> str | None:
"""The signed-in user's sign-in name, lowercase; None without Microsoft sign-in (e.g. dev)."""
user = request.headers.get("x-forwarded-email") or request.headers.get("x-forwarded-preferred-username")
return user.strip().lower() if user and user.strip() else None
def is_admin(request: Request) -> bool:
user = current_user(request)
return user is not None and user in request.app.state.settings.admins
def require_admin(request: Request) -> str:
if not is_admin(request):
raise HTTPException(403, "Admins only. Admins are listed in BACKUP_DASH_ADMINS on the server.")
return current_user(request)
# Changes made from the admin page must carry this header. A page on another site can't add a
# custom header to a request to the dashboard (the browser would first ask the API, which allows
# no other origins), so a signed-in admin's browser can't be used to forge them.
PAGE_HEADER = "X-Requested-By"
PAGE_HEADER_VALUE = "backup-dashboard"
def require_page_request(x_requested_by: str | None = Header(None)) -> None:
if x_requested_by != PAGE_HEADER_VALUE:
raise HTTPException(403, f"Missing {PAGE_HEADER} header")
def require_ingest_key(
authorization: str | None = Header(None),
conn: sqlite3.Connection = Depends(get_db),
+17 -2
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from fastapi import Request
SCHEMA_VERSION = 1
SCHEMA_VERSION = 2
SCHEMA = """
CREATE TABLE IF NOT EXISTS tenants (
@@ -67,6 +67,17 @@ def connect(path: Path | str) -> sqlite3.Connection:
return conn
# Changes since SCHEMA (which is v1), applied in order to bring any database up to SCHEMA_VERSION
MIGRATIONS = {
2: [
# Who created/revoked a key from the admin page, and which company it last reported for
"ALTER TABLE api_keys ADD COLUMN created_by TEXT",
"ALTER TABLE api_keys ADD COLUMN revoked_by TEXT",
"ALTER TABLE api_keys ADD COLUMN last_used_tenant TEXT",
],
}
def init_db(path: Path | str) -> None:
conn = connect(path)
try:
@@ -74,7 +85,11 @@ def init_db(path: Path | str) -> None:
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.executescript(SCHEMA) # v1 tables, for a new database
with transaction(conn):
for target in range(max(version, 1) + 1, SCHEMA_VERSION + 1):
for statement in MIGRATIONS[target]:
conn.execute(statement)
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
finally:
conn.close()
+55
View File
@@ -0,0 +1,55 @@
"""Collector API keys: create, list, revoke. Used by the admin API and manage.py."""
import re
import sqlite3
from typing import Any
from .auth import generate_key, hash_key
from .timeutil import to_db, utcnow
# Letters, digits, '.', '_', '-'; starts with a letter or digit. E.g. "veeam-acme", "mars.fs01"
NAME_PATTERN = r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}"
_FIELDS = "id, name, created_at, created_by, last_used_at, last_used_tenant, revoked_at, revoked_by"
class KeyNameTaken(Exception):
pass
def valid_name(name: str) -> bool:
return re.fullmatch(NAME_PATTERN, name) is not None
def list_keys(conn: sqlite3.Connection) -> list[dict[str, Any]]:
# Active keys first, then by name
rows = conn.execute(f"SELECT {_FIELDS} FROM api_keys ORDER BY revoked_at IS NOT NULL, name").fetchall()
return [dict(r) for r in rows]
def get_key(conn: sqlite3.Connection, key_id: int) -> dict[str, Any] | None:
row = conn.execute(f"SELECT {_FIELDS} FROM api_keys WHERE id = ?", (key_id,)).fetchone()
return dict(row) if row else None
def create_key(conn: sqlite3.Connection, name: str, created_by: str) -> tuple[dict[str, Any], str]:
"""Returns the stored key's record and the key itself, which isn't stored and can't be shown again."""
secret = generate_key()
try:
key_id = conn.execute(
"INSERT INTO api_keys (name, key_hash, created_at, created_by) VALUES (?, ?, ?, ?) RETURNING id",
(name, hash_key(secret), to_db(utcnow()), created_by),
).fetchone()[0]
except sqlite3.IntegrityError:
# Names stay taken after revocation, so the audit trail stays unambiguous
raise KeyNameTaken(name) from None
return get_key(conn, key_id), secret
def revoke_key(conn: sqlite3.Connection, key_id: int, revoked_by: str) -> dict[str, Any] | None:
"""Revokes the key (no-op if already revoked). Returns its record, or None if there's no such key."""
conn.execute(
"UPDATE api_keys SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL",
(to_db(utcnow()), revoked_by, key_id),
)
return get_key(conn, key_id)
+17 -2
View File
@@ -7,8 +7,8 @@ from pathlib import Path
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
from . import db, repo
from .auth import require_ingest_key
from . import admin, db, reports, repo
from .auth import is_admin, require_ingest_key
from .models import IngestIn
from .settings import HISTORY_DAYS, Settings, load_settings
from .timeutil import to_db, utcnow
@@ -25,6 +25,17 @@ def health(conn: sqlite3.Connection = Depends(db.get_db)):
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,
"is_admin": is_admin(request),
}
@router.get("/jobs")
def list_jobs(conn: sqlite3.Connection = Depends(db.get_db)):
return {"generated_at": to_db(utcnow()), "jobs": repo.list_jobs(conn)}
@@ -65,6 +76,8 @@ def ingest(
conn: sqlite3.Connection = Depends(db.get_db),
):
job_id, run_id = repo.ingest(conn, item)
# Shown on the admin page, so you can tell what each key is used for
conn.execute("UPDATE api_keys SET last_used_tenant = ? WHERE name = ?", (item.tenant, key_name))
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}
@@ -108,6 +121,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
)
app.state.settings = settings
app.include_router(router)
app.include_router(admin.router)
app.include_router(reports.router)
if settings.serve_static:
from fastapi.staticfiles import StaticFiles
+7
View File
@@ -42,6 +42,13 @@ def list_jobs(conn: sqlite3.Connection) -> list[dict[str, Any]]:
return [{k: row[k] for k in _SUMMARY_FIELDS} for row in rows]
def list_tenant_jobs(conn: sqlite3.Connection, tenant_id: int) -> list[dict[str, Any]]:
"""A company's jobs with their current status, for reports."""
rows = conn.execute(_JOB_SQL + " WHERE j.tenant_id = ? ORDER BY j.name", (tenant_id,)).fetchall()
fields = (*_SUMMARY_FIELDS, "last_status")
return [{k: row[k] for k in 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:
+143
View File
@@ -0,0 +1,143 @@
"""Per-company reports: every run of one company's backup jobs over a period, as JSON (for the
printable report page) or CSV. Read-only, like the dashboard, so any signed-in user may view them."""
import csv
import io
import re
import sqlite3
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import quote
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from . import db, repo
from .timeutil import DB_FORMAT, to_db, utcnow
router = APIRouter(prefix="/api/v1")
CSV_COLUMNS = ("Company", "Job", "Source", "Started", "Finished", "Duration (seconds)", "Status",
"Size (bytes)", "Message")
@router.get("/tenants")
def list_tenants(conn: sqlite3.Connection = Depends(db.get_db)):
"""Companies that have jobs, for choosing a report."""
rows = conn.execute(
"""
SELECT t.name, COUNT(j.id) AS jobs FROM tenants t JOIN jobs j ON j.tenant_id = t.id
GROUP BY t.id ORDER BY t.name COLLATE NOCASE
"""
).fetchall()
return {"tenants": [dict(r) for r in rows]}
def _load_report(request: Request, conn: sqlite3.Connection, tenant: str, days: int,
all_messages: bool = False) -> dict[str, Any]:
retention = request.app.state.settings.retention_days
if days > retention:
raise HTTPException(422, f"Reports can cover at most {retention} days: older runs aren't kept")
row = conn.execute("SELECT id, name FROM tenants WHERE name = ?", (tenant.strip(),)).fetchone() # NOCASE
if row is None:
raise HTTPException(404, f"No company named {tenant!r}")
now = utcnow()
since = now - timedelta(days=days)
jobs = repo.list_tenant_jobs(conn, row["id"])
runs_by_job: dict[int, list[dict[str, Any]]] = {job["id"]: [] for job in jobs}
# By default only warnings' and failures' messages: successes all say much the same, and an
# hourly job has thousands of runs in 90 days
for run in conn.execute(
"""
SELECT r.job_id, r.status, r.started_at, r.finished_at,
unixepoch(r.finished_at) - unixepoch(r.started_at) AS duration_seconds, r.size_bytes,
CASE WHEN ? OR r.status IN ('warning', 'failed') THEN r.message END AS message
FROM runs r JOIN jobs j ON j.id = r.job_id
WHERE j.tenant_id = ? AND r.started_at >= ?
ORDER BY r.started_at, r.id
""",
(all_messages, row["id"], to_db(since)),
):
run = dict(run)
runs_by_job[run.pop("job_id")].append(run)
# When the dashboard first knew of each job: its earliest run (collectors backfill history), or
# when it was first reported if earlier. Days before then are "not monitored", not "no backup".
tracked = dict(conn.execute(
"""
SELECT j.id, min(j.created_at, coalesce((SELECT MIN(r.started_at) FROM runs r WHERE r.job_id = j.id), j.created_at))
FROM jobs j WHERE j.tenant_id = ?
""",
(row["id"],),
).fetchall())
for job in jobs:
job["runs"] = runs_by_job[job["id"]]
job["tracked_since"] = tracked[job["id"]]
return {
"tenant": row["name"],
"days": days,
"since": to_db(since),
"until": to_db(now),
"generated_at": to_db(now),
"jobs": jobs,
}
@router.get("/reports/tenant")
def tenant_report(
request: Request,
tenant: str = Query(..., min_length=1),
days: int = Query(30, ge=1),
conn: sqlite3.Connection = Depends(db.get_db),
):
return _load_report(request, conn, tenant, days)
def _safe_cell(value: Any) -> Any:
"""Defuse text that a spreadsheet would run as a formula. Job names and messages come from the
backup servers, so a value like '=HYPERLINK(...)' must stay text."""
if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
return "'" + value
return value
@router.get("/reports/tenant.csv")
def tenant_report_csv(
request: Request,
tenant: str = Query(..., min_length=1),
days: int = Query(30, ge=1),
tz: str = Query("UTC", description="IANA time zone for the times, e.g. America/Edmonton"),
conn: sqlite3.Connection = Depends(db.get_db),
):
try:
zone = ZoneInfo(tz)
except (ZoneInfoNotFoundError, ValueError):
raise HTTPException(422, f"Unknown time zone {tz!r}") from None
report = _load_report(request, conn, tenant, days, all_messages=True)
def local(value: str | None) -> str:
if not value:
return ""
return datetime.strptime(value, DB_FORMAT).replace(tzinfo=UTC).astimezone(zone).strftime("%Y-%m-%d %H:%M:%S")
out = io.StringIO()
writer = csv.writer(out)
writer.writerow([c.replace("Started", f"Started ({tz})").replace("Finished", f"Finished ({tz})") for c in CSV_COLUMNS])
for job in report["jobs"]:
for run in job["runs"]:
writer.writerow([_safe_cell(v) for v in (
report["tenant"], job["job_name"], job["source_system"],
local(run["started_at"]), local(run["finished_at"]),
run["duration_seconds"] if run["duration_seconds"] is not None else "",
run["status"], run["size_bytes"] if run["size_bytes"] is not None else "",
run["message"] or "",
)])
slug = re.sub(r"[^A-Za-z0-9]+", "-", report["tenant"]).strip("-").lower() or "company"
filename = f"backup-report-{slug}-{datetime.now(zone):%Y-%m-%d}.csv"
return Response(
# BOM so Excel reads it as UTF-8 (accents, en dashes in job names)
content="" + out.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{quote(filename)}"},
)
+8
View File
@@ -15,6 +15,8 @@ class Settings:
retention_days: int = 90
# Dev only: serve ../public from the API so you can run without nginx.
serve_static: bool = False
# Microsoft 365 sign-in names allowed into the admin pages (lowercase)
admins: frozenset[str] = frozenset()
def read_env_file(path: Path) -> dict[str, str]:
@@ -40,4 +42,10 @@ def load_settings() -> 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",
admins=parse_admins(env.get("BACKUP_DASH_ADMINS", "")),
)
def parse_admins(value: str) -> frozenset[str]:
"""'[email protected], [email protected]' -> {'[email protected]', '[email protected]'}"""
return frozenset(part.strip().lower() for part in value.replace(";", ",").split(",") if part.strip())
+24 -17
View File
@@ -15,41 +15,48 @@ Run it as the service user so files it creates stay writable by the API:
"""
import argparse
import getpass
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 import db, keys, repo
from app.models import IngestIn
from app.settings import DEFAULT_ENV_FILE, load_settings
from app.timeutil import to_db, utcnow
from app.timeutil import utcnow
def cli_user() -> str:
# Recorded as who created/revoked a key; the admin page records the signed-in user instead
try:
return f"manage.py ({getpass.getuser()})"
except Exception:
return "manage.py"
def create_key(conn, args):
key = generate_key()
if not keys.valid_name(args.name):
sys.exit("Key names use letters, digits, '.', '_' and '-', starting with a letter or digit")
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:
_, secret = keys.create_key(conn, args.name, created_by=cli_user())
except keys.KeyNameTaken:
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}")
print(f"API key for {args.name!r} (shown once, store it in the collector's config):\n{secret}")
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'}")
for k in keys.list_keys(conn):
state = f"revoked {k['revoked_at']}" if k["revoked_at"] else "active"
tenant = f" ({k['last_used_tenant']})" if k["last_used_tenant"] else ""
print(f"{k['name']:<30} {state:<30} created {k['created_at']} last used {k['last_used_at'] or 'never'}{tenant}")
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}")
row = conn.execute("SELECT id FROM api_keys WHERE name = ? AND revoked_at IS NULL", (args.name,)).fetchone()
if row is None:
sys.exit(f"No active key named {args.name!r}")
keys.revoke_key(conn, row["id"], revoked_by=cli_user())
def list_jobs(conn, args):
+2
View File
@@ -1,2 +1,4 @@
fastapi>=0.115
uvicorn[standard]>=0.30
# Time zone data for report times, where the OS has none
tzdata
+146
View File
@@ -0,0 +1,146 @@
import sqlite3
import pytest
from fastapi.testclient import TestClient
from app import db
from app.main import create_app
from app.settings import Settings, parse_admins
ADMIN = {"X-Forwarded-Email": "[email protected]"}
PAGE = {"X-Requested-By": "backup-dashboard"}
ADMIN_PAGE = ADMIN | PAGE
@pytest.fixture
def client(tmp_path):
settings = Settings(db_path=tmp_path / "test.db", admins=frozenset({"[email protected]"}))
with TestClient(create_app(settings)) as c:
yield c
def create(client, name="veeam-acme", headers=ADMIN_PAGE):
return client.post("/api/v1/admin/keys", json={"name": name}, headers=headers)
def ingest(client, secret, tenant="Acme"):
return client.post(
"/api/v1/ingest",
json={"tenant": tenant, "source_system": "Veeam B&R", "job": {"external_id": "j1", "name": "Job"}},
headers={"Authorization": f"Bearer {secret}"},
)
def test_parse_admins():
assert parse_admins(" [email protected], [email protected];[email protected] ,") == {
"[email protected]", "[email protected]", "[email protected]"}
assert parse_admins("") == frozenset()
def test_me_says_whether_the_user_is_an_admin(client):
assert client.get("/api/v1/me", headers=ADMIN).json()["is_admin"] is True
assert client.get("/api/v1/me", headers={"X-Forwarded-Email": "[email protected]"}).json()["is_admin"] is False
assert client.get("/api/v1/me").json()["is_admin"] is False
@pytest.mark.parametrize("headers", [{}, {"X-Forwarded-Email": "[email protected]"}])
def test_admin_pages_are_admins_only(client, headers):
assert client.get("/api/v1/admin/keys", headers=headers).status_code == 403
assert create(client, headers=headers | PAGE).status_code == 403
assert client.post("/api/v1/admin/keys/1/revoke", headers=headers | PAGE).status_code == 403
def test_admin_match_ignores_case_and_accepts_the_sign_in_name(client):
assert client.get("/api/v1/admin/keys", headers={"X-Forwarded-Email": "[email protected]"}).status_code == 200
assert client.get("/api/v1/admin/keys", headers={"X-Forwarded-Preferred-Username": "[email protected]"}).status_code == 200
def test_changes_need_the_page_header(client):
# Without it, a page on another site could make a signed-in admin's browser send the request
assert create(client, headers=ADMIN).status_code == 403
secret = create(client).json()["secret"]
key_id = client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"][0]["id"]
assert client.post(f"/api/v1/admin/keys/{key_id}/revoke", headers=ADMIN).status_code == 403
assert ingest(client, secret).status_code == 200 # still active
@pytest.mark.parametrize("name", ["", " ", "-leading-dash", "has space", "a" * 65, "semi;colon"])
def test_rejects_bad_key_names(client, name):
assert create(client, name=name).status_code == 422
def test_created_key_is_shown_once_and_works_for_ingest(client):
r = create(client, name=" veeam-acme ")
assert r.status_code == 201
body = r.json()
assert body["secret"].startswith("bdk_")
assert body["key"]["name"] == "veeam-acme"
assert body["key"]["created_by"] == "[email protected]"
listed = client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"]
assert "secret" not in listed[0] and "key_hash" not in listed[0]
assert ingest(client, body["secret"]).status_code == 200
def test_key_records_when_and_for_which_company_it_was_last_used(client):
secret = create(client).json()["secret"]
ingest(client, secret, tenant="Acme Corp")
key = client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"][0]
assert key["last_used_at"] is not None
assert key["last_used_tenant"] == "Acme Corp"
def test_duplicate_names_are_refused_even_after_revoking(client):
key_id = create(client).json()["key"]["id"]
assert create(client).status_code == 409
client.post(f"/api/v1/admin/keys/{key_id}/revoke", headers=ADMIN_PAGE)
assert create(client).status_code == 409
def test_revoking_stops_the_key_and_records_who(client):
created = create(client).json()
r = client.post(f"/api/v1/admin/keys/{created['key']['id']}/revoke", headers=ADMIN_PAGE)
assert r.status_code == 200
assert r.json()["key"]["revoked_by"] == "[email protected]"
assert r.json()["key"]["revoked_at"] is not None
assert ingest(client, created["secret"]).status_code == 401
def test_revoking_twice_keeps_the_first_revocation(client):
key_id = create(client).json()["key"]["id"]
first = client.post(f"/api/v1/admin/keys/{key_id}/revoke", headers=ADMIN_PAGE).json()["key"]
again = client.post(f"/api/v1/admin/keys/{key_id}/revoke",
headers={"X-Forwarded-Email": "[email protected]"} | PAGE).json()["key"]
assert again["revoked_at"] == first["revoked_at"]
def test_revoking_an_unknown_key_is_404(client):
assert client.post("/api/v1/admin/keys/999/revoke", headers=ADMIN_PAGE).status_code == 404
def test_lists_active_keys_first(client):
for name in ("b-key", "a-key", "c-key"):
create(client, name=name)
a_id = next(k["id"] for k in client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"] if k["name"] == "a-key")
client.post(f"/api/v1/admin/keys/{a_id}/revoke", headers=ADMIN_PAGE)
names = [k["name"] for k in client.get("/api/v1/admin/keys", headers=ADMIN).json()["keys"]]
assert names == ["b-key", "c-key", "a-key"]
def test_upgrades_a_version_1_database_keeping_its_keys(tmp_path):
path = tmp_path / "old.db"
conn = sqlite3.connect(path)
conn.executescript(db.SCHEMA) # the v1 schema, as deployed before admin pages
conn.execute("INSERT INTO api_keys (name, key_hash, created_at) VALUES ('old-key', 'abc', '2026-10-01T00:00:00Z')")
conn.execute("PRAGMA user_version = 1")
conn.commit()
conn.close()
db.init_db(path)
db.init_db(path) # and running it again is harmless
conn = db.connect(path)
assert conn.execute("PRAGMA user_version").fetchone()[0] == db.SCHEMA_VERSION
row = dict(conn.execute("SELECT * FROM api_keys").fetchone())
assert row["name"] == "old-key"
assert {"created_by", "revoked_by", "last_used_tenant"} <= row.keys()
conn.close()
+6
View File
@@ -122,3 +122,9 @@ def test_tenant_name_is_case_insensitive(client):
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, "is_admin": False}
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", "is_admin": False}
+133
View File
@@ -0,0 +1,133 @@
import csv
import io
from datetime import UTC, datetime, 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", retention_days=90)
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 ('t', ?, ?)", (hash_key(key), to_db(utcnow())))
conn.close()
c.headers["Authorization"] = f"Bearer {key}"
yield c
def report_run(client, tenant, job, status, started, minutes=10, message="", size=None):
run = {"status": status, "started_at": started.isoformat(), "message": message}
if status != "running":
run["finished_at"] = (started + timedelta(minutes=minutes)).isoformat()
if size is not None:
run["size_bytes"] = size
r = client.post("/api/v1/ingest", json={
"tenant": tenant, "source_system": "Veeam B&R", "job": {"external_id": job, "name": job}, "run": run})
assert r.status_code == 200, r.text
@pytest.fixture
def alpha(client):
now = utcnow()
report_run(client, "Alpha Construction", "File Server", "success", now - timedelta(days=1), size=1000,
message="Job completed successfully.")
report_run(client, "Alpha Construction", "File Server", "failed", now - timedelta(days=2),
message="=HYPERLINK(\"http://evil\")\nRepository unreachable")
report_run(client, "Alpha Construction", "File Server", "success", now - timedelta(days=45)) # outside 30 days
report_run(client, "Alpha Construction", "SQL01", "warning", now - timedelta(hours=5), message="Log truncation skipped")
report_run(client, "Alpha Construction", "SQL01", "running", now - timedelta(minutes=5))
report_run(client, "Bluewater Dental", "Imaging", "success", now - timedelta(days=1))
return now
def test_lists_companies_with_jobs(client, alpha):
assert client.get("/api/v1/tenants").json()["tenants"] == [
{"name": "Alpha Construction", "jobs": 2}, {"name": "Bluewater Dental", "jobs": 1}]
def test_report_covers_one_company_for_the_period(client, alpha):
r = client.get("/api/v1/reports/tenant", params={"tenant": "alpha construction"}) # any case
assert r.status_code == 200
report = r.json()
assert report["tenant"] == "Alpha Construction"
assert report["days"] == 30
jobs = {j["job_name"]: j for j in report["jobs"]}
assert set(jobs) == {"File Server", "SQL01"} # not Bluewater's
assert [run["status"] for run in jobs["File Server"]["runs"]] == ["failed", "success"] # oldest first; 45-day-old run left out
assert [run["status"] for run in jobs["SQL01"]["runs"]] == ["warning", "running"]
assert jobs["SQL01"]["status"] == "running"
def test_report_says_when_each_job_was_first_tracked(client, alpha):
jobs = {j["job_name"]: j for j in client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction"}).json()["jobs"]}
# File Server's earliest run (45 days ago, outside the report) predates its first report to the API
assert jobs["File Server"]["tracked_since"] == to_db(alpha - timedelta(days=45))
# SQL01's runs are all recent; it was first reported (created) when its first run arrived
assert jobs["SQL01"]["tracked_since"] <= to_db(alpha - timedelta(hours=5))
def test_report_includes_messages_only_for_warnings_and_failures(client, alpha):
jobs = {j["job_name"]: j for j in client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction"}).json()["jobs"]}
failed, success = jobs["File Server"]["runs"]
assert "Repository unreachable" in failed["message"]
assert success["message"] is None
assert success["size_bytes"] == 1000
assert success["duration_seconds"] == 600
def test_report_period_is_limited_to_retention(client, alpha):
assert client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 90}).status_code == 200
r = client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 91})
assert r.status_code == 422
assert "90 days" in r.json()["detail"]
assert client.get("/api/v1/reports/tenant", params={"tenant": "Alpha Construction", "days": 0}).status_code == 422
def test_unknown_company_is_404(client, alpha):
assert client.get("/api/v1/reports/tenant", params={"tenant": "Nobody Inc"}).status_code == 404
def read_csv(response):
text = response.content.decode("utf-8")
assert text.startswith("") # so Excel reads it as UTF-8
return list(csv.reader(io.StringIO(text[1:])))
def test_csv_has_every_run_with_times_in_the_given_zone(client):
started = datetime(2026, 1, 15, 12, 0, tzinfo=UTC) # 05:00 in Edmonton (MST, -7)
# The fixture's timestamps are relative to now; this run needs a fixed winter date in range
client.app.state.settings = Settings(db_path=client.app.state.settings.db_path, retention_days=10_000)
report_run(client, "Alpha Construction", "File Server", "success", started, minutes=20, size=5,
message="Job completed successfully.")
r = client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction", "days": 5000, "tz": "America/Edmonton"})
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/csv")
rows = read_csv(r)
assert rows[0] == ["Company", "Job", "Source", "Started (America/Edmonton)", "Finished (America/Edmonton)",
"Duration (seconds)", "Status", "Size (bytes)", "Message"]
assert rows[1] == ["Alpha Construction", "File Server", "Veeam B&R", "2026-01-15 05:00:00", "2026-01-15 05:20:00",
"1200", "success", "5", "Job completed successfully."]
def test_csv_defuses_spreadsheet_formulas_and_keeps_full_messages(client, alpha):
rows = read_csv(client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction"}))
by_status = {row[6]: row for row in rows[1:]}
assert by_status["failed"][8].startswith("'=HYPERLINK")
assert by_status["success"][8] == "Job completed successfully." # successes' messages too, unlike the JSON
assert by_status["running"][4] == "" # not finished
assert len(rows) == 1 + 4 # header + the 4 runs in the last 30 days
def test_csv_filename_and_bad_time_zone(client, alpha):
r = client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction"})
assert 'filename="backup-report-alpha-construction-' in r.headers["content-disposition"]
assert client.get("/api/v1/reports/tenant.csv", params={"tenant": "Alpha Construction", "tz": "Mars/Olympus"}).status_code == 422
@@ -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
+14 -49
View File
@@ -29,9 +29,6 @@ param(
)
$ErrorActionPreference = 'Stop'
$TaskName = 'Backup Dashboard - MARS collector'
$DataDir = Join-Path $env:ProgramData 'BackupDashboard'
$ConfigPath = Join-Path $DataDir 'mars-collector.json'
$WindowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
# Check the agent is here before changing anything
@@ -40,51 +37,19 @@ if (-not (Get-Module -ListAvailable -Name MSOnlineBackup) -and -not (Test-Path $
throw 'The MARS agent (MSOnlineBackup PowerShell module) is not installed on this server.'
}
if ($ApiUrl -notmatch '^https://') {
Write-Warning 'ApiUrl is not HTTPS: the API key would be sent in clear text.'
# 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
# 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
Tenant = $Tenant
SourceSystem = $SourceSystem
LookbackHours = $LookbackHours
}
if ($JobName) { $config.JobName = $JobName }
$config | ConvertTo-Json | Set-Content -Path $ConfigPath -Encoding UTF8
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
$scriptPath = Join-Path $InstallDir 'Send-MarsBackupStatus.ps1'
Copy-Item -Path (Join-Path $PSScriptRoot 'Send-MarsBackupStatus.ps1') -Destination $scriptPath -Force
Unblock-File -Path $scriptPath
$action = New-ScheduledTaskAction -Execute $WindowsPowerShell `
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`""
$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
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
-MultipleInstances IgnoreNew -StartWhenAvailable
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal `
-Settings $settings -Description 'Sends Azure Backup (MARS) results to the Backup Dashboard' -Force | Out-Null
Write-Host "Installed. Config: $ConfigPath"
Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM, using Windows PowerShell."
Write-Host ''
Write-Host 'Next steps:'
Write-Host " 1. Preview what will be sent: & '$WindowsPowerShell' -File '$scriptPath' -DryRun"
Write-Host " 2. Backfill 30 days of history: & '$WindowsPowerShell' -File '$scriptPath' -LookbackHours 720"
Write-Host " 3. Check the log: Get-Content '$DataDir\mars-collector.log' -Tail 20"
$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
+6 -4
View File
@@ -14,11 +14,12 @@ named `<SERVER> - Azure Backup` by default.
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:
1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host:
```bash
python manage.py create-key mars-acme-fs01
```
2. Copy this folder to the server and run:
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"
```
@@ -33,8 +34,9 @@ targets and which every supported Windows Server has.
powershell.exe -File 'C:\Program Files\BackupDashboard\Send-MarsBackupStatus.ps1' -LookbackHours 720
```
It can be installed alongside the Veeam collector: it uses its own config, state, log and task.
Re-run the installer to change settings or update the script.
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
+18 -111
View File
@@ -38,63 +38,22 @@ param(
$ErrorActionPreference = 'Stop'
$DataDir = Split-Path -Parent $ConfigPath
$LogPath = Join-Path $DataDir 'mars-collector.log'
$StatePath = Join-Path $DataDir 'mars-collector.state.json'
$MaxLogBytes = 5MB
# 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
$Invariant = [Globalization.CultureInfo]::InvariantCulture
# ---------- Helpers ----------
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 ($DryRun) { return }
if ((Test-Path $LogPath) -and (Get-Item $LogPath).Length -gt $MaxLogBytes) {
Move-Item $LogPath "$LogPath.1" -Force
}
Add-Content -Path $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.
function ConvertTo-IsoTime($Value) {
if ($null -eq $Value) { return $null }
$dt = [datetime]$Value
if ($dt.Year -le 1900) { return $null } # "not set" (MARS uses 1601-01-01)
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant)
}
# First property of $Object, out of $Names, that exists and isn't empty. MARS object property names
# aren't 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
}
function Import-Mars {
if (Get-Command Get-OBJob -ErrorAction SilentlyContinue) { return } # already loaded
if (Get-Module -ListAvailable -Name MSOnlineBackup) {
@@ -305,49 +264,16 @@ function Test-IsBackupJob($Job) {
return (-not $type) -or $type -match 'Backup'
}
function Send-Report($Body) {
$json = $Body | ConvertTo-Json -Depth 6 -Compress
if ($DryRun) {
Write-Host $json
return
}
# Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1
$params = @{
Uri = "$($Config.ApiUrl.TrimEnd('/'))/api/v1/ingest"
Method = 'Post'
Headers = @{ Authorization = "Bearer $($Config.ApiKey)" }
ContentType = 'application/json; charset=utf-8'
Body = [Text.Encoding]::UTF8.GetBytes($json)
TimeoutSec = 30
}
Invoke-RestMethod @params | Out-Null
}
# ---------- Main ----------
try {
$Config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
foreach ($key in 'ApiUrl', 'ApiKey', 'Tenant') {
if (-not $Config.$key) { throw "Config $ConfigPath is missing '$key'" }
}
$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" }
if ($LookbackHours -le 0) {
$LookbackHours = if ($Config.LookbackHours) { [int]$Config.LookbackHours } else { 48 }
}
# Windows PowerShell 5.1 may default to TLS 1.0
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$LookbackHours = $Config.LookbackHours
Import-Mars
# Job ID -> start time (Unix seconds), for jobs already delivered in their final state
$sent = @{}
if (Test-Path $StatePath) {
$saved = Get-Content -Path $StatePath -Raw | ConvertFrom-Json
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $sent[$p.Name] = [long]$p.Value } }
}
$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 {
@@ -360,7 +286,7 @@ try {
$byId[[string]$job.JobId] = $job # a job in both lists: the finished entry (later) wins
}
$pending = @($byId.Values |
Where-Object { -not $sent.ContainsKey([string]$_.JobId) } |
Where-Object { -not (Test-RunSent ([string]$_.JobId)) } |
Sort-Object { Get-JobTimeUtc $_ 'StartTime' })
$jobInfo = [ordered]@{
@@ -376,38 +302,19 @@ try {
}
$failures = 0
$runsSent = 0
try {
if ($pending.Count -eq 0) {
# Keep next_run_at fresh even when nothing new ran
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo })
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo
}
foreach ($job in $pending) {
$run = ConvertTo-Run $job
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo; run = $run })
$runsSent++
if ($run.status -ne 'running') {
$sent[$run.external_id] = ([DateTimeOffset](Get-JobTimeUtc $job 'StartTime')).ToUnixTimeSeconds()
}
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'
}
if (-not $DryRun) {
# Forget jobs that have aged out of the lookback window (plus a day of slack)
$cutoff = [DateTimeOffset]::Now.AddHours(-$LookbackHours - 24).ToUnixTimeSeconds()
$keep = [ordered]@{}
foreach ($id in $sent.Keys) {
if ($sent[$id] -ge $cutoff) { $keep[$id] = $sent[$id] }
}
try {
$keep | ConvertTo-Json -Compress | Set-Content -Path $StatePath -Encoding UTF8
} catch {
Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN'
}
}
Write-Log ("Reported {0} runs, {1} failures" -f $runsSent, $failures)
if ($failures) { exit 1 }
$exitCode = Complete-Collector -Failures $failures
if ($exitCode) { exit $exitCode }
+2 -1
View File
@@ -7,7 +7,8 @@
# ./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
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { 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)))
+139
View File
@@ -0,0 +1,139 @@
# The collector installers, with stand-ins for the Windows-only commands they call (icacls, Task
# Scheduler, Unblock-File, the API key prompt). Checks what each would write and register.
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$collectorsDir = Split-Path -Parent $PSScriptRoot
$shell = if ($env:COLLECTOR_TEST_SHELL) { $env:COLLECTOR_TEST_SHELL } else { (Get-Process -Id $PID).Path }
$pwshPath = (Get-Process -Id $PID).Path # stands in for an installed PowerShell 7
# Runs one installer in a fresh process and sandbox; returns what it wrote and registered
function Invoke-Installer {
param([string]$Collector, [string]$Arguments, [switch]$NoProductModule)
$sandbox = New-TestDir 'installer'
$src = Join-Path $sandbox 'collectors'
Copy-Item -Path $collectorsDir -Destination $src -Recurse
$installer = (Get-ChildItem (Join-Path $src $Collector) -Filter 'Install-*.ps1').FullName
# The test runner isn't elevated
(Get-Content $installer) -notmatch '^#Requires -RunAsAdministrator' | Set-Content $installer
foreach ($d in 'ProgramData', 'ProgramFiles', 'SystemRoot') { New-Item -ItemType Directory -Path (Join-Path $sandbox $d) | Out-Null }
$record = Join-Path $sandbox 'record.txt'
$stubs = @'
function global:Record($what) { Add-Content $env:REC_FILE $what }
function global:Read-Host { param([switch]$AsSecureString, $Prompt) ConvertTo-SecureString 'bdk_testkey' -AsPlainText -Force }
function global:icacls.exe { Record "icacls $($args -join ' ')"; $global:LASTEXITCODE = 0 }
function global:Unblock-File { param($Path) Record "unblock $(Split-Path -Leaf $Path)" }
function global:Get-Module { param([switch]$ListAvailable, $Name) if (-not $env:STUB_NO_MODULE) { [pscustomobject]@{ Name = $Name } } }
function global:New-ScheduledTaskAction { param($Execute, $Argument) Record "exe $Execute"; Record "args $Argument" }
function global:New-ScheduledTaskTrigger { param([switch]$Once, $At, $RepetitionInterval) Record "every $([int]$RepetitionInterval.TotalMinutes)" }
function global:New-ScheduledTaskPrincipal { param($UserId, $LogonType, $RunLevel) Record "principal $UserId $LogonType $RunLevel" }
function global:New-ScheduledTaskSettingsSet { param($ExecutionTimeLimit, $MultipleInstances, [switch]$StartWhenAvailable) Record "settings $MultipleInstances" }
function global:Register-ScheduledTask { param($TaskName, $Action, $Trigger, $Principal, $Settings, $Description, [switch]$Force) Record "task $TaskName" }
'@
$env:REC_FILE = $record
$env:STUB_NO_MODULE = if ($NoProductModule) { '1' } else { '' }
$saved = @{ ProgramData = $env:ProgramData; ProgramFiles = $env:ProgramFiles; SystemRoot = $env:SystemRoot }
try {
foreach ($d in $saved.Keys) { Set-Item "env:$d" (Join-Path $sandbox $d) }
Push-Location (Split-Path $installer)
$output = @(& $shell -NoProfile -NonInteractive -Command "$stubs; & '$installer' $Arguments" 2>&1 | ForEach-Object { "$_" })
} finally {
Pop-Location
foreach ($d in $saved.Keys) { Set-Item "env:$d" $saved[$d] }
}
$configFile = Get-ChildItem (Join-Path (Join-Path $sandbox 'ProgramData') 'BackupDashboard') -Filter '*-collector.json' -ErrorAction SilentlyContinue
$installDir = Join-Path (Join-Path $sandbox 'ProgramFiles') 'BackupDashboard'
return [pscustomobject]@{
Output = $output -join "`n"
Config = if ($configFile) { Get-Content $configFile.FullName -Raw | ConvertFrom-Json } else { $null }
Files = @(Get-ChildItem $installDir -ErrorAction SilentlyContinue | ForEach-Object Name | Sort-Object)
Record = @(if (Test-Path $record) { Get-Content $record })
Sandbox = $sandbox
}
}
}
Describe 'Collector installers' {
Context 'Veeam' {
BeforeAll { $i = Invoke-Installer veeam "-ApiUrl https://dash.example.com/ -Tenant 'Acme Corp' -PwshPath '$pwshPath'" }
It 'writes the config, including the API key from the prompt' {
$i.Config.ApiUrl | Should -Be 'https://dash.example.com'
$i.Config.ApiKey | Should -Be 'bdk_testkey'
$i.Config.Tenant | Should -Be 'Acme Corp'
$i.Config.SourceSystem | Should -Be 'Veeam B&R'
$i.Config.LookbackHours | Should -Be 48
}
It 'locks the config folder to SYSTEM and Administrators' {
$i.Record | Where-Object { $_ -like 'icacls*' } | Should -BeLike '*/inheritance:r /grant:r `*S-1-5-18:(OI)(CI)F `*S-1-5-32-544:(OI)(CI)F'
}
It 'installs the collector and the shared module side by side, unblocked' {
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-VeeamBackupStatus.ps1')
$i.Record | Should -Contain 'unblock BackupDashboard.Collector.psm1'
$i.Record | Should -Contain 'unblock Send-VeeamBackupStatus.ps1'
}
It 'registers an hourly SYSTEM task running the collector under PowerShell 7' {
$i.Record | Should -Contain "exe $pwshPath"
$i.Record | Where-Object { $_ -like 'args*' } | Should -BeLike '*-File "*Send-VeeamBackupStatus.ps1"'
$i.Record | Should -Contain 'every 60'
$i.Record | Should -Contain 'principal S-1-5-18 ServiceAccount Highest'
$i.Record | Should -Contain 'settings IgnoreNew'
$i.Record | Should -Contain 'task Backup Dashboard - Veeam collector'
}
It 'refuses a PowerShell 7 path that does not exist, before changing anything' {
$bad = Invoke-Installer veeam "-ApiUrl https://dash.example.com -Tenant Acme -PwshPath '/no/such/pwsh.exe'"
$bad.Output | Should -BeLike '*PowerShell 7 not found*'
$bad.Config | Should -BeNullOrEmpty
$bad.Files | Should -BeNullOrEmpty
}
}
Context 'MARS' {
It 'writes JobName, runs hourly under Windows PowerShell, and honours -IntervalMinutes' {
$i = Invoke-Installer mars "-ApiUrl https://dash.example.com -Tenant 'Acme Corp' -JobName 'File Server' -IntervalMinutes 30"
$i.Config.JobName | Should -Be 'File Server'
$i.Config.SourceSystem | Should -Be 'Azure Backup (MARS)'
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-MarsBackupStatus.ps1')
$i.Record | Where-Object { $_ -like 'exe*' } | Should -BeLike '*WindowsPowerShell*powershell.exe'
$i.Record | Should -Contain 'every 30'
$i.Record | Should -Contain 'task Backup Dashboard - MARS collector'
}
It 'refuses to install where the MARS agent is missing, before changing anything' {
$i = Invoke-Installer mars "-ApiUrl https://dash.example.com -Tenant Acme" -NoProductModule
$i.Output | Should -BeLike '*MARS agent*not installed*'
$i.Config | Should -BeNullOrEmpty
}
}
Context 'Windows Server Backup' {
It 'warns when the dashboard URL is not HTTPS' {
$i = Invoke-Installer wsb "-ApiUrl http://dash.example.com -Tenant 'Acme Corp'"
$i.Output | Should -BeLike '*not HTTPS*'
$i.Files | Should -Be @('BackupDashboard.Collector.psm1', 'Send-WsbBackupStatus.ps1')
$i.Record | Should -Contain 'task Backup Dashboard - WSB collector'
}
It 'refuses to install without the Windows Server Backup feature' {
$i = Invoke-Installer wsb "-ApiUrl https://dash.example.com -Tenant Acme" -NoProductModule
# (PowerShell wraps long error messages, so match a short part)
$i.Output | Should -BeLike '*WindowsServerBackup PowerShell module is not available*'
$i.Config | Should -BeNullOrEmpty
}
}
Context 'VB365' {
It 'writes the organization-to-company map, with no Tenant unless given' {
$i = Invoke-Installer vb365 "-ApiUrl https://dash.example.com -OrganizationTenants @{ 'acme.onmicrosoft.com' = 'Acme Corp'; 'blue.onmicrosoft.com' = 'Bluewater' }"
$i.Config.OrganizationTenants.'acme.onmicrosoft.com' | Should -Be 'Acme Corp'
$i.Config.OrganizationTenants.'blue.onmicrosoft.com' | Should -Be 'Bluewater'
$i.Config.PSObject.Properties.Name | Should -Not -Contain 'Tenant'
$i.Record | Where-Object { $_ -like 'exe*' } | Should -BeLike '*powershell.exe'
}
It 'uses PowerShell 7 when given -PwshPath' {
$i = Invoke-Installer vb365 "-ApiUrl https://dash.example.com -Tenant 'Solo Co' -PwshPath '$pwshPath'"
$i.Config.Tenant | Should -Be 'Solo Co'
$i.Record | Should -Contain "exe $pwshPath"
}
}
}
@@ -0,0 +1,62 @@
<#
.SYNOPSIS
Runs the collector test suite (Pester): the shared module, each collector against its mock,
and the installers.
.DESCRIPTION
Needs PowerShell 7.2+ and Pester 5.5+:
Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser
Each collector runs in its own process, by default under the same PowerShell running the
tests. On Windows, use -CollectorShell to run them under Windows PowerShell 5.1 instead, as
the MARS, WSB and VB365 servers do.
Exits non-zero if any test fails, so it can gate a commit or CI job.
.EXAMPLE
./collectors/tests/Invoke-CollectorTests.ps1
.EXAMPLE
./collectors/tests/Invoke-CollectorTests.ps1 -CollectorShell powershell.exe
.EXAMPLE
./collectors/tests/Invoke-CollectorTests.ps1 -Path ./collectors/tests/Wsb.Tests.ps1
#>
#Requires -Version 7.2
[CmdletBinding()]
param(
# powershell.exe / pwsh / a full path: the shell the collectors and installers run under
[string]$CollectorShell,
# Test files or folders to run; defaults to every *.Tests.ps1 here
[string[]]$Path = @($PSScriptRoot),
[ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')][string]$Output = 'Detailed'
)
$ErrorActionPreference = 'Stop'
$pester = Get-Module -ListAvailable -Name Pester | Where-Object Version -GE '5.5' |
Sort-Object Version -Descending | Select-Object -First 1
if (-not $pester) {
throw 'Pester 5.5 or newer is needed: Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser'
}
Import-Module $pester
if ($CollectorShell) {
$resolved = Get-Command $CollectorShell -ErrorAction SilentlyContinue
if (-not $resolved) { throw "Shell not found: $CollectorShell" }
$env:COLLECTOR_TEST_SHELL = $resolved.Source
}
$shellName = if ($env:COLLECTOR_TEST_SHELL) { $env:COLLECTOR_TEST_SHELL } else { "this PowerShell ($($PSVersionTable.PSVersion))" }
Write-Host "Collectors run under: $shellName"
# All temp folders for this run, removed at the end
$env:COLLECTOR_TEST_TEMP = Join-Path ([IO.Path]::GetTempPath()) ("backup-dashboard-tests-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
try {
$config = New-PesterConfiguration
$config.Run.Path = $Path
$config.Run.PassThru = $true
$config.Output.Verbosity = $Output
$result = Invoke-Pester -Configuration $config
} finally {
Remove-Item -Recurse -Force $env:COLLECTOR_TEST_TEMP -ErrorAction SilentlyContinue
Remove-Item env:COLLECTOR_TEST_TEMP
}
exit $(if ($result.Result -eq 'Passed') { 0 } else { 1 })
+95
View File
@@ -0,0 +1,95 @@
# Azure Backup (MARS agent) collector, against collectors/mars/tests/MockMars.ps1 (shapes from a
# real agent, 2.0.9274: job times are UTC values with no UTC marking, ErrorInfo is always present).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
# MARS reports job times in UTC; the API should get them with +00:00
function Utc([datetime]$Local) { ConvertTo-ApiTime $Local.ToUniversalTime() }
}
Describe 'MARS collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector mars -Config $config -DryRun
$runs = @($r.Reports | Where-Object run | ForEach-Object run)
$byStart = @{}
foreach ($run in $runs) { $byStart[$run.started_at] = $run }
}
It 'runs cleanly' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 4 runs, 0 failures'
}
It 'reports the server as one job, named after it' {
$r.Reports.job.external_id | Select-Object -Unique | Should -Be 'mars:srv01'
$r.Reports.job.name | Select-Object -Unique | Should -Be 'SRV01 - Azure Backup'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Azure Backup (MARS)'
}
It 'computes the next run from the schedule (daily at 21:00)' {
$next = (Get-Date).Date.AddHours(21)
if ((Get-Date) -ge $next) { $next = $next.AddDays(1) }
$r.Reports[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
}
It 'treats job times as UTC (MARS gives UTC values without marking them)' {
$byStart.Keys | Should -Contain (Utc $now.AddHours(-12))
$byStart[(Utc $now.AddHours(-12))].finished_at | Should -Be (Utc $now.AddHours(-11.5))
}
It 'reports a completed backup as success, despite its (always present) ErrorInfo with code 0' {
$run = $byStart[(Utc $now.AddHours(-12))]
$run.status | Should -Be 'success'
$run.message | Should -Be 'Backup completed successfully.'
$run.size_bytes | Should -Be 523407872
$run.details.datasources | Should -Be @('E:\')
}
It 'reports a completed backup with failed files as a warning, with the count and log' {
$run = $byStart[(Utc $now.AddHours(-36))]
$run.status | Should -Be 'warning'
$run.message | Should -BeLike '*E:\: 12 file(s) failed to back up*'
$run.message | Should -BeLike '*Failed file log: C:\Program Files\Microsoft Azure Recovery Services Agent\Temp\Errors.log*'
$run.details.failed_files | Should -Be 12
$run.details.failed_bytes | Should -Be 40MB
}
It 'reports a failed backup with its error code and parameters' {
$run = $byStart[(Utc $now.AddHours(-30))]
$run.status | Should -Be 'failed'
$run.message | Should -BeLike '*Error code 100050 (Volume=E:)*'
}
It 'reports a backup in progress as running, without a finish time' {
$run = $byStart[(Utc $now.AddMinutes(-10))]
$run.status | Should -Be 'running'
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
}
It 'ignores restores and backups outside the lookback window' {
$runs.Count | Should -Be 4
}
It 'uses JobName from the config when set' {
$named = Invoke-Collector mars -Config ($config + @{ JobName = 'File Server' }) -DryRun
$named.Reports.job.name | Select-Object -Unique | Should -Be 'File Server'
$named.Reports.job.external_id | Select-Object -Unique | Should -Be 'mars:srv01'
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $config.Clone(); $live.ApiUrl = $stub.Url
$first = Invoke-Collector mars -Config $live
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector mars -Config $live -WorkDir $first.WorkDir
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every run on the first run' {
$first.ExitCode | Should -Be 0
$firstRequests.Count | Should -Be 4
}
It 'on the next run, resends only the backup in progress' {
$first.State.Count | Should -Be 3
$secondRequests.Count | Should -Be 1
$secondRequests[0].Payload.run.status | Should -Be 'running'
}
}
}
+136
View File
@@ -0,0 +1,136 @@
# Tests for collectors/common/BackupDashboard.Collector.psm1, the code all collectors share.
BeforeAll {
Import-Module (Join-Path (Split-Path -Parent $PSScriptRoot) 'common/BackupDashboard.Collector.psm1') -Force
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
function New-TempConfig([hashtable]$Values, [hashtable]$State) {
$dir = New-TestDir 'module'
$path = Join-Path $dir 'test-collector.json'
$Values | ConvertTo-Json | Set-Content -Path $path
if ($State) { $State | ConvertTo-Json | Set-Content -Path (Join-Path $dir 'test-collector.state.json') }
return $path
}
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
}
Describe 'Get-FirstValue' {
It 'returns the first property that has a value' {
Get-FirstValue ([pscustomobject]@{ A = $null; B = ''; C = 'yes' }) @('A', 'B', 'C') | Should -Be 'yes'
}
It 'treats an empty collection as no value' {
Get-FirstValue ([pscustomobject]@{ A = @(); B = 'yes' }) @('A', 'B') | Should -Be 'yes'
}
It 'returns a one-item list of objects (which [string] would turn into an empty string)' {
$item = [pscustomobject]@{ Name = 'E:\' }
$result = @(Get-FirstValue ([pscustomobject]@{ List = @($item) }) @('List'))
$result.Count | Should -Be 1
$result[0].Name | Should -Be 'E:\'
}
It 'returns $null for a missing property or object' {
Get-FirstValue ([pscustomobject]@{ A = 1 }) @('Nope') | Should -BeNullOrEmpty
Get-FirstValue $null @('A') | Should -BeNullOrEmpty
}
It 'keeps $false (a real value, not "missing")' {
Get-FirstValue ([pscustomobject]@{ Enabled = $false }) @('Enabled') | Should -BeFalse
$null -eq (Get-FirstValue ([pscustomobject]@{ Enabled = $false }) @('Enabled')) | Should -BeFalse
}
}
Describe 'ConvertTo-IsoTime' {
It 'gives local times their offset' {
$local = [datetime]::new(2026, 10, 8, 21, 0, 0, [DateTimeKind]::Local)
ConvertTo-IsoTime $local | Should -Be ([DateTimeOffset]$local).ToString("yyyy-MM-dd'T'HH:mm:sszzz")
}
It 'gives UTC times +00:00' {
ConvertTo-IsoTime ([datetime]::new(2026, 10, 8, 3, 0, 5, [DateTimeKind]::Utc)) | Should -Be '2026-10-08T03:00:05+00:00'
}
It 'treats "not set" values as no time' {
ConvertTo-IsoTime ([datetime]::MinValue) | Should -BeNullOrEmpty
ConvertTo-IsoTime ([datetime]::new(1601, 1, 1)) | Should -BeNullOrEmpty # MARS
ConvertTo-IsoTime ([datetime]::new(1900, 1, 1)) | Should -BeNullOrEmpty # Veeam
ConvertTo-IsoTime $null | Should -BeNullOrEmpty
}
}
Describe 'Initialize-Collector' {
It 'requires ApiUrl, ApiKey and Tenant by default' {
$path = New-TempConfig @{ ApiUrl = 'http://x'; ApiKey = 'bdk_x' }
{ Initialize-Collector -Name 'test' -ConfigPath $path } | Should -Throw "*missing 'Tenant'*"
}
It 'accepts a custom set of required keys' {
$path = New-TempConfig @{ ApiUrl = 'http://x'; ApiKey = 'bdk_x' }
{ Initialize-Collector -Name 'test' -ConfigPath $path -RequiredKeys 'ApiUrl', 'ApiKey' } | Should -Not -Throw
}
It 'resolves LookbackHours: parameter, then config, then 48' {
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig $config)).LookbackHours | Should -Be 48
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig ($config + @{ LookbackHours = 720 }))).LookbackHours | Should -Be 720
(Initialize-Collector -Name 'test' -ConfigPath (New-TempConfig ($config + @{ LookbackHours = 720 })) -LookbackHours 12).LookbackHours | Should -Be 12
}
It 'loads runs already sent from the state file' {
$path = New-TempConfig $config @{ 'run-1' = [DateTimeOffset]::Now.ToUnixTimeSeconds() }
$null = Initialize-Collector -Name 'test' -ConfigPath $path
Test-RunSent 'run-1' | Should -BeTrue
Test-RunSent 'run-2' | Should -BeFalse
}
}
Describe 'Send-JobReport and Complete-Collector' {
BeforeEach {
$path = New-TempConfig $config @{
'recent' = [DateTimeOffset]::Now.AddHours(-10).ToUnixTimeSeconds()
'stale' = [DateTimeOffset]::Now.AddHours(-100).ToUnixTimeSeconds() # > 48h + 1 day ago
}
$statePath = Join-Path (Split-Path $path) 'test-collector.state.json'
$job = [ordered]@{ external_id = 'j1'; name = 'Job'; next_run_at = $null }
}
It 'records a finished run as sent, but not a running one' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
$null = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'success' }) -SentKey 'done' -StartedAt (Get-Date) 6>&1
$null = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'running' }) -SentKey 'busy' -StartedAt (Get-Date) 6>&1
Test-RunSent 'done' | Should -BeTrue
Test-RunSent 'busy' | Should -BeFalse
}
It 'prints the payload in a dry run, with the job and run' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
$printed = Send-JobReport -Tenant 'Acme' -SourceSystem 'Test' -Job $job -Run ([ordered]@{ status = 'success' }) -SentKey 'x' -StartedAt (Get-Date) 6>&1
$payload = "$printed" | ConvertFrom-Json
$payload.tenant | Should -Be 'Acme'
$payload.source_system | Should -Be 'Test'
$payload.job.external_id | Should -Be 'j1'
$payload.run.status | Should -Be 'success'
}
It 'saves the state, dropping runs older than the lookback window plus a day' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path
$null = Complete-Collector -Failures 0 6>&1
$saved = Get-Content $statePath -Raw | ConvertFrom-Json
$saved.PSObject.Properties.Name | Should -Be @('recent')
}
It "doesn't touch the state in a dry run" {
$before = Get-Content $statePath -Raw
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
$null = Complete-Collector -Failures 0 6>&1
Get-Content $statePath -Raw | Should -Be $before
}
It 'returns exit code 1 when anything failed, 0 otherwise' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
Complete-Collector -Failures 0 6>$null | Should -Be 0
Complete-Collector -Failures 2 6>$null | Should -Be 1
}
It 'logs a summary with or without a job count' {
$null = Initialize-Collector -Name 'test' -ConfigPath $path -DryRun
"$(Complete-Collector -JobCount 3 -Failures 0 6>&1 | Select-Object -First 1)" | Should -Match 'Reported 3 jobs, 0 runs, 0 failures$'
"$(Complete-Collector -Failures 1 6>&1 | Select-Object -First 1)" | Should -Match 'Reported 0 runs, 1 failures$'
}
}
Describe 'Test-AuthRejected' {
It 'is true only for HTTP 401' {
$error401 = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = [pscustomobject]@{ StatusCode = 401 } } }
$error500 = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = [pscustomobject]@{ StatusCode = 500 } } }
$noResponse = [pscustomobject]@{ Exception = [pscustomobject]@{ Message = 'timeout' } }
Test-AuthRejected $error401 | Should -BeTrue
Test-AuthRejected $error500 | Should -BeFalse
Test-AuthRejected $noResponse | Should -BeFalse
}
}
+43
View File
@@ -0,0 +1,43 @@
# Collector tests
Pester tests for the collectors, the shared module and the installers. Each collector runs against
its mock (`collectors/<name>/tests/Mock*.ps1`, built from real servers' diagnostic output) and a
stand-in for the ingest API, so no backup product or dashboard is needed.
```powershell
Install-Module Pester -MinimumVersion 5.5 -Scope CurrentUser # once
./collectors/tests/Invoke-CollectorTests.ps1 # everything, ~15 s
./collectors/tests/Invoke-CollectorTests.ps1 -Path ./collectors/tests/Wsb.Tests.ps1
```
Needs PowerShell 7.2+ to run the tests. Collectors run in their own process, under the same
PowerShell by default. On Windows, run them under Windows PowerShell 5.1, as the MARS, WSB and
VB365 servers do:
```powershell
./collectors/tests/Invoke-CollectorTests.ps1 -CollectorShell powershell.exe
```
The runner exits non-zero if anything fails.
## What's covered
| File | Checks |
|---|---|
| `Module.Tests.ps1` | `BackupDashboard.Collector.psm1`: property lookup, time format, config and lookback, state (recording sent runs, pruning), dry runs, exit codes, 401 detection |
| `Veeam.Tests.ps1` | Next run from the live schedule (VBR 13), none for copy/disabled jobs; copy-job worker sessions; statuses and messages; state and resending; rejected key; missing config |
| `Mars.Tests.ps1` | UTC job times; ErrorCode 0 isn't an error; failed files → warning; next run from the schedule; JobName; state |
| `Wsb.Tests.ps1` | Culture-formatted times (en-US and en-GB); VMs nested in VirtualMachineList; HRESULTs; failure log only when relevant; running → finished merging on start time |
| `Vb365.Tests.ps1` | Organization → company mapping; `[Warning]`/`[Failed]` log prefixes; retries; "187.2 MB" sizes; state |
| `Installers.Tests.ps1` | Config written (incl. the VB365 map), files copied (collector + module), task registered (shell, interval, SYSTEM), refusals before changing anything |
Each of these was a real bug or a real-server finding. To check the suite still catches them,
put one back (e.g. read `$Job.ScheduleOptions.NextRun` instead of `GetScheduleOptions()` in the
Veeam collector) and run the tests: they should fail.
## Adding a collector
1. Write `collectors/<name>/tests/Mock<Name>.ps1` from the product's diagnostic output, taking
its time from `$env:MOCK_NOW` like the others.
2. Add `<name>` to `Invoke-Collector`'s `ValidateSet` in `TestHelpers.psm1`.
3. Add `<Name>.Tests.ps1`, one `It` per behaviour confirmed on a real server.
+166
View File
@@ -0,0 +1,166 @@
<#
Helpers for the collector tests: run a collector against its mock in a separate PowerShell
process, and stand in for the dashboard's ingest API.
Each collector run gets its own process so the mocks' global functions and the collector's
`exit` don't leak between tests. The shell is the one running the tests, or
$env:COLLECTOR_TEST_SHELL (e.g. powershell.exe, to run the collectors under Windows
PowerShell 5.1 like most servers do).
#>
$script:CollectorsDir = Split-Path -Parent $PSScriptRoot
$script:Invariant = [Globalization.CultureInfo]::InvariantCulture
# Temp folders for this test run live under one root, which Invoke-CollectorTests.ps1 removes
function New-TestDir([string]$Prefix = 'test') {
$root = if ($env:COLLECTOR_TEST_TEMP) { $env:COLLECTOR_TEST_TEMP } else { Join-Path ([IO.Path]::GetTempPath()) 'backup-dashboard-tests' }
$dir = Join-Path $root ("$Prefix-" + [guid]::NewGuid().ToString('N').Substring(0, 12))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
return $dir
}
function Get-TestShell {
if ($env:COLLECTOR_TEST_SHELL) { return $env:COLLECTOR_TEST_SHELL }
return (Get-Process -Id $PID).Path
}
<#
Pins the time the mocks are relative to (the top of the current hour) for this test session,
so a test can't fail because the hour turned over between the run and the assertions.
Returns it as a local DateTime.
#>
function Set-MockNow {
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
$env:MOCK_NOW = $now.ToString('o', $script:Invariant)
return [datetime]$env:MOCK_NOW
}
# The API's time format: "2026-10-08T21:00:00-06:00"
function ConvertTo-ApiTime([datetime]$Value) {
return ([DateTimeOffset]$Value).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $script:Invariant)
}
# JSON with date-looking strings left as strings (PowerShell 7.5+ would otherwise make them DateTimes)
function ConvertFrom-JsonText([string]$Json) {
if ((Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind')) {
return $Json | ConvertFrom-Json -DateKind String
}
return $Json | ConvertFrom-Json
}
function ConvertTo-PSLiteral([string]$Value) { return "'" + $Value.Replace("'", "''") + "'" }
<#
Runs one collector against its mock. Returns:
ExitCode the collector's exit code (0 when it ends normally)
Reports in -DryRun, the payloads it would have sent
Log its log lines (timestamp stripped), from the console
State the sent-runs state file, as a hashtable (empty if none)
WorkDir pass back in to run again with the same config and state
#>
function Invoke-Collector {
param(
[Parameter(Mandatory, Position = 0)][ValidateSet('veeam', 'mars', 'wsb', 'vb365')][string]$Name,
[Parameter(Mandatory)][hashtable]$Config,
[string]$WorkDir,
[switch]$DryRun,
[string]$MockArgs = '',
[string]$Culture = 'en-US',
[string]$ExtraArgs = '',
[string]$ComputerName = 'SRV01'
)
$dir = Join-Path $script:CollectorsDir $Name
$script = (Get-ChildItem (Join-Path $dir 'Send-*.ps1')).FullName
$mock = (Get-ChildItem (Join-Path (Join-Path $dir 'tests') 'Mock*.ps1')).FullName
if (-not $WorkDir) {
$WorkDir = New-TestDir $Name
}
$configPath = Join-Path $WorkDir "$Name-collector.json"
$Config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8
$command = @(
"[Globalization.CultureInfo]::CurrentCulture = '$Culture'"
". $(ConvertTo-PSLiteral $mock) $MockArgs"
"& $(ConvertTo-PSLiteral $script) -ConfigPath $(ConvertTo-PSLiteral $configPath) $(if ($DryRun) { '-DryRun' }) $ExtraArgs"
'exit $LASTEXITCODE'
) -join '; '
$savedComputer = $env:COMPUTERNAME
$env:COMPUTERNAME = $ComputerName
try {
$output = @(& (Get-TestShell) -NoProfile -NonInteractive -Command $command 2>&1 | ForEach-Object { "$_" })
$exitCode = $LASTEXITCODE
} finally {
$env:COMPUTERNAME = $savedComputer
}
$state = @{}
$statePath = Join-Path $WorkDir "$Name-collector.state.json"
if (Test-Path $statePath) {
$saved = ConvertFrom-JsonText (Get-Content $statePath -Raw)
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $state[$p.Name] = $p.Value } }
}
return [pscustomobject]@{
ExitCode = $exitCode
# Payloads are single-line JSON; a logged API error body spans several lines starting with '{'
Reports = @($output | Where-Object { $_ -match '^\{".*\}$' } | ForEach-Object { ConvertFrom-JsonText $_ })
Log = @($output | Where-Object { $_ -match '^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d \[' } | ForEach-Object { $_.Substring(20) })
Output = $output
State = $state
WorkDir = $WorkDir
}
}
<#
A stand-in for the ingest API on a free localhost port. Records every request; answers 401
for API keys containing "bad", 200 otherwise. Stop it with Stop-ApiStub.
#>
function Start-ApiStub {
$probe = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
$probe.Start(); $port = $probe.LocalEndpoint.Port; $probe.Stop()
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add("http://localhost:$port/")
$listener.Start()
$requests = [Collections.Concurrent.ConcurrentQueue[object]]::new()
$job = Start-ThreadJob -ArgumentList $listener, $requests -ScriptBlock {
param($listener, $requests)
while ($listener.IsListening) {
try { $context = $listener.GetContext() } catch { break }
$body = [IO.StreamReader]::new($context.Request.InputStream, [Text.Encoding]::UTF8).ReadToEnd()
$auth = $context.Request.Headers['Authorization']
$requests.Enqueue([pscustomobject]@{
Path = $context.Request.Url.AbsolutePath; Auth = $auth
ContentType = $context.Request.ContentType; Body = $body
})
$rejected = $auth -match 'bad'
$reply = if ($rejected) { '{"detail":"Invalid or revoked API key"}' } else { '{"job_id":1,"run_id":1}' }
$bytes = [Text.Encoding]::UTF8.GetBytes($reply)
$context.Response.StatusCode = $(if ($rejected) { 401 } else { 200 })
$context.Response.ContentType = 'application/json'
$context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
$context.Response.Close()
}
}
return [pscustomobject]@{ Url = "http://localhost:$port"; Listener = $listener; Requests = $requests; Job = $job }
}
# Takes the requests recorded since the last call; Body is the parsed JSON payload
function Get-StubRequests($Stub) {
$taken = @()
$item = $null
while ($Stub.Requests.TryDequeue([ref]$item)) {
$item | Add-Member -NotePropertyName Payload -NotePropertyValue (ConvertFrom-JsonText $item.Body) -Force
$taken += $item
}
return , $taken
}
function Stop-ApiStub($Stub) {
$Stub.Listener.Stop()
$Stub.Listener.Close()
$Stub.Job | Stop-Job -PassThru | Remove-Job -Force
}
Export-ModuleMember -Function New-TestDir, Set-MockNow, ConvertTo-ApiTime, ConvertFrom-JsonText, Invoke-Collector,
Start-ApiStub, Get-StubRequests, Stop-ApiStub
+116
View File
@@ -0,0 +1,116 @@
# Veeam Backup for Microsoft 365 collector, against collectors/vb365/tests/MockVb365.ps1 (shapes from
# a VB365 8.4 server: log items carry their status as a Title prefix, retries are separate sessions).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$base = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x' }
function Get-JobRuns($Reports, [string]$JobName) {
@($Reports | Where-Object { $_.job.name -eq $JobName -and $_.run } | ForEach-Object run)
}
function Get-Tenants($Reports) {
$map = @{}
foreach ($report in $Reports) { $map[$report.job.name] = $report.tenant }
return $map
}
}
Describe 'VB365 collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector vb365 -Config ($base + @{ Tenant = 'Default Co' }) -DryRun
$acme = Get-JobRuns $r.Reports 'Acme 365 Backup'
$blue = Get-JobRuns $r.Reports 'Bluewater 365 Backup'
}
It 'runs cleanly, reporting every job' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 3 jobs, 5 runs, 0 failures'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Veeam Backup for M365'
}
It "uses each job's NextRun, and none for a disabled job" {
$next = $now.Date.AddHours(17).AddDays($(if ($now.Hour -ge 17) { 1 } else { 0 }))
($r.Reports | Where-Object { $_.job.name -eq 'Acme 365 Backup' })[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
($r.Reports | Where-Object { $_.job.name -eq 'Bluewater Old Mailboxes' })[0].job.next_run_at | Should -BeNullOrEmpty
}
It 'reports a successful session with its size ("187.2 MB" in bytes) and objects' {
$run = $acme | Where-Object status -EQ 'success'
$run.started_at | Should -Be (ConvertTo-ApiTime $now.Date.AddDays(-1).AddHours(17))
$run.size_bytes | Should -Be ([long](187.2 * 1MB))
$run.details.processed_objects | Should -Be 568
$run.details.organization | Should -Be 'acme.onmicrosoft.com'
$run.message | Should -Be 'Job completed successfully.'
}
It 'reports a session in progress as running, without a finish time' {
$run = $acme | Where-Object status -EQ 'running'
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.message | Should -Be ''
}
It 'builds the message from [Warning]/[Failed] log items, without the prefix' {
$run = $blue | Where-Object status -EQ 'warning'
$run.message | Should -BeLike 'Processing mailbox [email protected] finished with warning: Item "Q3.pst" exceeds the maximum size*'
# Literal check: in -like patterns, [Warning] is a character set
$run.message.Contains('[Warning]') | Should -BeFalse
}
It 'ignores [Success] log items, even ones mentioning "error"' {
($blue | Where-Object status -EQ 'warning').message | Should -Not -BeLike '*error-reports*'
}
It 'flags a session VB365 will retry, and links the retry back to it' {
$warning = $blue | Where-Object status -EQ 'warning'
$retry = $blue | Where-Object { $_.details.retry_of }
$warning.message | Should -BeLike '*Veeam will retry this job.'
$warning.details.will_retry | Should -BeTrue
$retry.status | Should -Be 'success'
$retry.details.retry_of | Should -Be $warning.external_id
}
It 'reports a failed session with its error' {
$run = $blue | Where-Object status -EQ 'failed'
$run.message | Should -BeLike '*(401) Unauthorized*'
}
It 'skips sessions outside the lookback window' {
Get-JobRuns $r.Reports 'Bluewater Old Mailboxes' | Should -BeNullOrEmpty
}
}
Context 'which company a job appears under' {
It 'uses OrganizationTenants (not case-sensitive), then Tenant for unmapped organizations' {
$r = Invoke-Collector vb365 -DryRun -Config ($base + @{
Tenant = 'Default Co'; OrganizationTenants = @{ 'ACME.onmicrosoft.com' = 'Acme Corp' } })
$tenants = Get-Tenants $r.Reports
$tenants['Acme 365 Backup'] | Should -Be 'Acme Corp'
$tenants['Bluewater 365 Backup'] | Should -Be 'Default Co'
}
It "falls back to the organization's name with no mapping and no Tenant" {
$r = Invoke-Collector vb365 -Config $base -DryRun
$r.ExitCode | Should -Be 0
$tenants = Get-Tenants $r.Reports
$tenants['Acme 365 Backup'] | Should -Be 'acme.onmicrosoft.com'
$tenants['Bluewater 365 Backup'] | Should -Be 'bluewater.onmicrosoft.com'
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $base + @{ Tenant = 'Default Co' }; $live.ApiUrl = $stub.Url
$first = Invoke-Collector vb365 -Config $live
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector vb365 -Config $live -WorkDir $first.WorkDir
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every session, remembering the finished ones' {
$first.ExitCode | Should -Be 0
@($firstRequests | Where-Object { $_.Payload.run }).Count | Should -Be 5
$first.State.Count | Should -Be 4
}
It 'on the next run, resends only the running session (and refreshes every job)' {
$resent = @($secondRequests | Where-Object { $_.Payload.run })
$resent.Count | Should -Be 1
$resent[0].Payload.run.status | Should -Be 'running'
$secondRequests.Count | Should -Be 3
}
}
}
+127
View File
@@ -0,0 +1,127 @@
# Veeam B&R collector, against collectors/veeam/tests/MockVeeam.ps1 (shapes from a VBR 13 server).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$dash = [char]0x2013 # job names use an en dash, as real ones often do
$fileServer = "File Server $dash Nightly"
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
function Get-Runs($Reports, [string]$JobName) {
@($Reports | Where-Object { $_.job.name -eq $JobName -and $_.run } | ForEach-Object run)
}
}
Describe 'Veeam collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector veeam -Config $config -DryRun
$jobs = @{}
foreach ($report in $r.Reports) { $jobs[$report.job.name] = $report.job }
}
It 'runs cleanly' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 5 jobs, 6 runs, 0 failures'
}
It 'reports every job, under the configured tenant' {
$jobs.Keys | Sort-Object | Should -Be @('Cloud Copy', $fileServer, 'Offsite Copy', 'Old Exchange', "SQL01 $dash Logs")
$r.Reports.tenant | Select-Object -Unique | Should -Be 'Acme'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Veeam B&R'
}
It "reads the next run from the live schedule (VBR 13's ScheduleOptions property is empty)" {
$jobs[$fileServer].next_run_at | Should -Be (ConvertTo-ApiTime $now.Date.AddDays(1).AddMinutes(15))
$jobs["SQL01 $dash Logs"].next_run_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(40))
}
It 'gives no next run to immediate-mode copy jobs (their NextRun is stale), disabled or unscheduled jobs' {
$jobs['Cloud Copy'].next_run_at | Should -BeNullOrEmpty
$jobs['Old Exchange'].next_run_at | Should -BeNullOrEmpty
$jobs['Offsite Copy'].next_run_at | Should -BeNullOrEmpty
}
It 'reports a successful session with its times and size' {
$run = Get-Runs $r.Reports $fileServer | Where-Object status -EQ 'success'
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddDays(-1).AddHours(-2))
$run.finished_at | Should -Be (ConvertTo-ApiTime $now.AddDays(-1).AddHours(-1))
$run.size_bytes | Should -Be 128GB
$run.message | Should -Be 'Job completed successfully.'
$run.details.objects | Should -Be 2
}
It 'reports a session in progress as running, without a finish time' {
$run = Get-Runs $r.Reports $fileServer | Where-Object status -EQ 'running'
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(-20))
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.message | Should -Be ''
}
It 'names the object behind a warning' {
$run = Get-Runs $r.Reports "SQL01 $dash Logs"
$run.status | Should -Be 'warning'
$run.message | Should -BeLike 'SQL01: Warning - Failed to truncate transaction logs*'
$run.details.objects_warning | Should -Be 1
}
It "includes a failure's reason and failed log lines, but not successful ones" {
$run = Get-Runs $r.Reports 'Offsite Copy'
$run.status | Should -Be 'failed'
$run.message | Should -BeLike '*Unable to connect to repository NAS02*'
$run.message | Should -BeLike '*Network path not found (0x80070035)*'
$run.message | Should -Not -BeLike '*Preparing backup chain*'
}
It "files copy jobs' worker sessions under the copy job, naming the source" {
$runs = Get-Runs $r.Reports 'Cloud Copy'
$runs.status | Sort-Object | Should -Be @('success', 'warning')
$runs.details.copied_from | Select-Object -Unique | Should -Be $fileServer
}
It "skips sessions outside the lookback window and from jobs it doesn't know" {
@($r.Reports | Where-Object run).Count | Should -Be 6
Get-Runs $r.Reports 'Old Exchange' | Should -BeNullOrEmpty
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $config.Clone(); $live.ApiUrl = $stub.Url; $live.ApiKey = 'bdk_good'
$first = Invoke-Collector veeam -Config $live
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector veeam -Config $live -WorkDir $first.WorkDir
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'posts each report to /api/v1/ingest with the API key, as UTF-8 JSON' {
$first.ExitCode | Should -Be 0
$firstRequests.Count | Should -Be 7
$firstRequests.Path | Select-Object -Unique | Should -Be '/api/v1/ingest'
$firstRequests.Auth | Select-Object -Unique | Should -Be 'Bearer bdk_good'
$firstRequests.ContentType | Select-Object -Unique | Should -Be 'application/json; charset=utf-8'
}
It 'keeps non-ASCII job names intact' {
$firstRequests.Payload.job.name | Should -Contain $fileServer
}
It 'remembers finished sessions, but not running ones' {
$first.State.Count | Should -Be 5
}
It 'on the next run, resends only the running session (and refreshes every job)' {
$resent = @($secondRequests | Where-Object { $_.Payload.run })
$resent.Count | Should -Be 1
$resent[0].Payload.run.status | Should -Be 'running'
$secondRequests.Count | Should -Be 5
}
}
Context 'problems' {
It 'stops after the first job when the API key is rejected, exiting 1' {
$stub = Start-ApiStub
try {
$r = Invoke-Collector veeam -Config @{ ApiUrl = $stub.Url; ApiKey = 'bdk_bad'; Tenant = 'Acme' }
(Get-StubRequests $stub).Count | Should -Be 1
$r.ExitCode | Should -Be 1
$r.Log | Should -Contain '[ERROR] API key rejected; check ApiKey in the config. Stopping.'
} finally { Stop-ApiStub $stub }
}
It 'exits 2 when the config lacks a tenant' {
$r = Invoke-Collector veeam -Config @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x' } -DryRun
$r.ExitCode | Should -Be 2
$r.Log[0] | Should -BeLike "*is missing 'Tenant'*"
}
}
}
+108
View File
@@ -0,0 +1,108 @@
# Windows Server Backup collector, against collectors/wsb/tests/MockWsb.ps1 (shapes from a Server
# 2019 Hyper-V host: job times are culture-formatted local-time strings, HRESULTs are strings,
# VMs are nested under a VirtualMachineList item).
BeforeAll {
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
$now = Set-MockNow
$config = @{ ApiUrl = 'http://localhost:1'; ApiKey = 'bdk_x'; Tenant = 'Acme' }
$vms = @('AAOG-EDM-AD1', 'Host Component')
$hostComponentBytes = 447729
}
Describe 'Windows Server Backup collector' {
Context 'what it reports (dry run)' {
BeforeAll {
$r = Invoke-Collector wsb -Config $config -DryRun
$runs = @($r.Reports | Where-Object run | ForEach-Object run)
$byStart = @{}
foreach ($run in $runs) { $byStart[$run.started_at] = $run }
function At([double]$HoursAgo) { $byStart[(ConvertTo-ApiTime $now.AddHours(-$HoursAgo))] }
}
It 'runs cleanly' {
$r.ExitCode | Should -Be 0
$r.Log[-1] | Should -Be '[INFO] Reported 4 runs, 0 failures'
}
It 'reports the server as one job, with the next backup from Get-WBSummary' {
$r.Reports.job.external_id | Select-Object -Unique | Should -Be 'wsb:srv01'
$r.Reports.job.name | Select-Object -Unique | Should -Be 'SRV01 - Windows Server Backup'
$r.Reports.source_system | Select-Object -Unique | Should -Be 'Windows Server Backup'
$next = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
$r.Reports[0].job.next_run_at | Should -Be (ConvertTo-ApiTime $next)
}
It 'reads job times as local time, and sends no run ID (WSB jobs have none; the API keys on start time)' {
(At 12).finished_at | Should -Be (ConvertTo-ApiTime $now.AddHours(-11.6))
$runs | ForEach-Object { $_.PSObject.Properties.Name | Should -Not -Contain 'external_id' }
}
It 'reports a Hyper-V backup with its VMs (nested in VirtualMachineList) and size' {
$run = At 12
$run.status | Should -Be 'success'
$run.message | Should -Be 'Backup completed successfully.'
$run.details.items | Should -Be $vms
$run.size_bytes | Should -Be (124756706036 + $hostComponentBytes)
}
It "doesn't cite the failure log for a success (WSB fills it in regardless)" {
(At 12).message | Should -Not -BeLike '*Failure log*'
}
It 'reports a backup where one VM failed as a warning, naming the VM' {
$run = At 36
$run.status | Should -Be 'warning'
$run.message | Should -BeLike '*AAOG-EDM-AD1 - Access is denied.*'
$run.message | Should -BeLike '*Failure log: C:\Windows\Logs\WindowsServerBackup\Backup_Error.log*'
}
It 'reports a backup with an error HRESULT as failed, with the code in 0x8 form' {
$run = At 30
$run.status | Should -Be 'failed'
$run.message | Should -BeLike '*The backup storage location could not be found.*'
$run.message | Should -BeLike '*error 0x80042302*'
}
It 'reports a volume backup with its volumes and size' {
$run = At 20
$run.status | Should -Be 'success'
$run.details.items | Should -Be @('C:', 'D:')
$run.size_bytes | Should -Be (240GB)
}
It "ignores restores, old backups, and Get-WBJob's empty job when nothing is running" {
$runs.Count | Should -Be 4
}
It 'reads dd/MM dates correctly on servers with other regional settings' {
$gb = Invoke-Collector wsb -Config $config -DryRun -Culture 'en-GB'
($gb.Reports | ConvertTo-Json -Depth 6) | Should -Be ($r.Reports | ConvertTo-Json -Depth 6)
}
It 'reports a backup in progress as running, with no message' {
$running = Invoke-Collector wsb -Config $config -DryRun -MockArgs '-WithRunningJob'
$run = $running.Reports | Where-Object { $_.run.status -eq 'running' } | ForEach-Object run
$run.started_at | Should -Be (ConvertTo-ApiTime $now.AddMinutes(-10))
$run.message | Should -Be ''
$run.PSObject.Properties.Name | Should -Not -Contain 'finished_at'
$run.details.items | Should -Be $vms
}
}
Context 'sending to the API' {
BeforeAll {
$stub = Start-ApiStub
$live = $config.Clone(); $live.ApiUrl = $stub.Url
$first = Invoke-Collector wsb -Config $live -MockArgs '-WithRunningJob'
$firstRequests = Get-StubRequests $stub
$second = Invoke-Collector wsb -Config $live -WorkDir $first.WorkDir -MockArgs '-RunningJobFinished'
$secondRequests = Get-StubRequests $stub
}
AfterAll { Stop-ApiStub $stub }
It 'sends every run, remembering the finished ones' {
$first.ExitCode | Should -Be 0
$firstRequests.Count | Should -Be 5
$first.State.Count | Should -Be 4
}
It 'sends the finished backup once it ends, with the same start time, so the API merges it into the running one' {
$running = $firstRequests.Payload.run | Where-Object status -EQ 'running'
$secondRequests.Count | Should -Be 1
$finished = $secondRequests[0].Payload.run
$finished.status | Should -Be 'success'
$finished.started_at | Should -Be $running.started_at
$second.State.Count | Should -Be 5
}
}
}
@@ -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. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host:
```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 }
+79
View File
@@ -0,0 +1,79 @@
# 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
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { 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 { }
+13 -47
View File
@@ -30,9 +30,6 @@ param(
)
$ErrorActionPreference = 'Stop'
$TaskName = 'Backup Dashboard - Veeam collector'
$DataDir = Join-Path $env:ProgramData 'BackupDashboard'
$ConfigPath = Join-Path $DataDir 'veeam-collector.json'
# Find PowerShell 7 before changing anything, so a missing install fails cleanly
if (-not $PwshPath) {
@@ -54,49 +51,18 @@ if ($LASTEXITCODE -or ($pwshMajor -as [int]) -lt 7) {
throw "'$PwshPath' isn't a working PowerShell 7 (reported '$pwshVersion')."
}
if ($ApiUrl -notmatch '^https://') {
Write-Warning 'ApiUrl is not HTTPS: the API key would be sent in clear text.'
# 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
# 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" }
[ordered]@{
ApiUrl = $ApiUrl.TrimEnd('/')
ApiKey = $apiKey
Tenant = $Tenant
SourceSystem = $SourceSystem
LookbackHours = $LookbackHours
} | ConvertTo-Json | Set-Content -Path $ConfigPath -Encoding UTF8
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
$scriptPath = Join-Path $InstallDir 'Send-VeeamBackupStatus.ps1'
Copy-Item -Path (Join-Path $PSScriptRoot 'Send-VeeamBackupStatus.ps1') -Destination $scriptPath -Force
Unblock-File -Path $scriptPath
$action = New-ScheduledTaskAction -Execute $PwshPath `
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`""
$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
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
-MultipleInstances IgnoreNew -StartWhenAvailable
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal `
-Settings $settings -Description 'Sends Veeam job results to the Backup Dashboard' -Force | Out-Null
Write-Host "Installed. Config: $ConfigPath"
Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM, using PowerShell $pwshVersion ($PwshPath)."
Write-Host ''
Write-Host 'Next steps:'
Write-Host " 1. Preview what will be sent: & '$PwshPath' -File '$scriptPath' -DryRun"
Write-Host " 2. Backfill 30 days of history: & '$PwshPath' -File '$scriptPath' -LookbackHours 720"
Write-Host " 3. Check the log: Get-Content '$DataDir\veeam-collector.log' -Tail 20"
$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
+5 -3
View File
@@ -9,11 +9,12 @@ Requires PowerShell 7 installed machine-wide (`winget install Microsoft.PowerShe
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:
1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host:
```bash
python manage.py create-key veeam-acme
```
2. Copy this folder to the VBR server and run, from PowerShell 7 (`pwsh`):
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"
```
@@ -29,7 +30,8 @@ account can't run it. The installer checks for this before changing anything.
& 'C:\Program Files\BackupDashboard\Send-VeeamBackupStatus.ps1' -LookbackHours 720
```
Re-run the installer to change settings or update the script. To remove the collector,
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
+18 -98
View File
@@ -37,42 +37,19 @@ param(
$ErrorActionPreference = 'Stop'
$DataDir = Split-Path -Parent $ConfigPath
$LogPath = Join-Path $DataDir 'veeam-collector.log'
$StatePath = Join-Path $DataDir 'veeam-collector.state.json'
$MaxLogBytes = 5MB
$Invariant = [Globalization.CultureInfo]::InvariantCulture
# 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 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 ($DryRun) { return }
if ((Test-Path $LogPath) -and (Get-Item $LogPath).Length -gt $MaxLogBytes) {
Move-Item $LogPath "$LogPath.1" -Force
}
Add-Content -Path $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. Veeam returns local times, often with Kind=Unspecified,
# which DateTimeOffset treats as local, exactly what we want.
function ConvertTo-IsoTime($Value) {
if ($null -eq $Value) { return $null }
$dt = [datetime]$Value
if ($dt.Year -le 1900) { return $null } # Veeam's "not set" value (e.g. EndTime while running)
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant)
}
function Import-Veeam {
if (Get-Command Get-VBRJob -ErrorAction SilentlyContinue) { return } # already loaded
if (Get-Module -ListAvailable -Name Veeam.Backup.PowerShell) {
@@ -217,51 +194,15 @@ function ConvertTo-Run($Session, $Job) {
return $run
}
function Send-Report($Body) {
$json = $Body | ConvertTo-Json -Depth 6 -Compress
if ($DryRun) {
Write-Host $json
return
}
# Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1,
# mangling non-ASCII job names (e.g. en dashes).
$params = @{
Uri = "$($Config.ApiUrl.TrimEnd('/'))/api/v1/ingest"
Method = 'Post'
Headers = @{ Authorization = "Bearer $($Config.ApiKey)" }
ContentType = 'application/json; charset=utf-8'
Body = [Text.Encoding]::UTF8.GetBytes($json)
TimeoutSec = 30
}
Invoke-RestMethod @params | Out-Null
}
# ---------- Main ----------
try {
$Config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
foreach ($key in 'ApiUrl', 'ApiKey', 'Tenant') {
if (-not $Config.$key) { throw "Config $ConfigPath is missing '$key'" }
}
$Config = Initialize-Collector -Name 'veeam' -ConfigPath $ConfigPath -LookbackHours $LookbackHours -DryRun:$DryRun
$SourceSystem = if ($Config.SourceSystem) { [string]$Config.SourceSystem } else { 'Veeam B&R' }
if ($LookbackHours -le 0) {
$LookbackHours = if ($Config.LookbackHours) { [int]$Config.LookbackHours } else { 48 }
}
# Windows PowerShell 5.1 may default to TLS 1.0
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$LookbackHours = $Config.LookbackHours
Import-Veeam
# Session ID -> start time (Unix seconds), for sessions 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.
$sent = @{}
if (Test-Path $StatePath) {
$saved = Get-Content -Path $StatePath -Raw | ConvertFrom-Json
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $sent[$p.Name] = [long]$p.Value } }
}
$since = (Get-Date).AddHours(-$LookbackHours)
$jobs = @(Get-VBRJob -WarningAction SilentlyContinue)
@@ -288,7 +229,6 @@ try {
}
$failures = 0
$runsSent = 0
foreach ($job in $jobs) {
$jobInfo = [ordered]@{
external_id = $job.Id.ToString()
@@ -299,48 +239,28 @@ foreach ($job in $jobs) {
$pending = @()
if ($sessionsByJob.ContainsKey($jobInfo.external_id)) {
$pending = @($sessionsByJob[$jobInfo.external_id] |
Where-Object { -not $sent.ContainsKey($_.Id.ToString()) } |
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-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo })
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo
}
foreach ($session in $pending) {
$run = ConvertTo-Run $session $job
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo; run = $run })
$runsSent++
if ($run.status -ne 'running') {
$sent[$run.external_id] = ([DateTimeOffset][datetime]$session.CreationTime).ToUnixTimeSeconds()
}
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'
$httpStatus = 0
try { $httpStatus = [int]$_.Exception.Response.StatusCode } catch { }
if ($httpStatus -eq 401) {
if (Test-AuthRejected $_) {
Write-Log 'API key rejected; check ApiKey in the config. Stopping.' 'ERROR'
break
}
}
}
if (-not $DryRun) {
# Forget sessions that have aged out of the lookback window (plus a day of slack)
$cutoff = [DateTimeOffset]::Now.AddHours(-$LookbackHours - 24).ToUnixTimeSeconds()
$keep = [ordered]@{}
foreach ($id in $sent.Keys) {
if ($sent[$id] -ge $cutoff) { $keep[$id] = $sent[$id] }
}
try {
$keep | ConvertTo-Json -Compress | Set-Content -Path $StatePath -Encoding UTF8
} catch {
Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN'
}
}
Write-Log ("Reported {0} jobs, {1} runs, {2} failures" -f $jobs.Count, $runsSent, $failures)
if ($failures) { exit 1 }
$exitCode = Complete-Collector -JobCount $jobs.Count -Failures $failures
if ($exitCode) { exit $exitCode }
+2 -1
View File
@@ -6,7 +6,8 @@
# 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
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { 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)))
}
+14 -49
View File
@@ -29,9 +29,6 @@ param(
)
$ErrorActionPreference = 'Stop'
$TaskName = 'Backup Dashboard - WSB collector'
$DataDir = Join-Path $env:ProgramData 'BackupDashboard'
$ConfigPath = Join-Path $DataDir 'wsb-collector.json'
$WindowsPowerShell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
# Check Windows Server Backup is here before changing anything
@@ -39,51 +36,19 @@ 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).'
}
if ($ApiUrl -notmatch '^https://') {
Write-Warning 'ApiUrl is not HTTPS: the API key would be sent in clear text.'
# 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
# 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
Tenant = $Tenant
SourceSystem = $SourceSystem
LookbackHours = $LookbackHours
}
if ($JobName) { $config.JobName = $JobName }
$config | ConvertTo-Json | Set-Content -Path $ConfigPath -Encoding UTF8
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
$scriptPath = Join-Path $InstallDir 'Send-WsbBackupStatus.ps1'
Copy-Item -Path (Join-Path $PSScriptRoot 'Send-WsbBackupStatus.ps1') -Destination $scriptPath -Force
Unblock-File -Path $scriptPath
$action = New-ScheduledTaskAction -Execute $WindowsPowerShell `
-Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`""
$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
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
-MultipleInstances IgnoreNew -StartWhenAvailable
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Principal $principal `
-Settings $settings -Description 'Sends Windows Server Backup results to the Backup Dashboard' -Force | Out-Null
Write-Host "Installed. Config: $ConfigPath"
Write-Host "Task '$TaskName' runs every $IntervalMinutes minutes as SYSTEM, using Windows PowerShell."
Write-Host ''
Write-Host 'Next steps:'
Write-Host " 1. Preview what will be sent: & '$WindowsPowerShell' -File '$scriptPath' -DryRun"
Write-Host " 2. Backfill 30 days of history: & '$WindowsPowerShell' -File '$scriptPath' -LookbackHours 720"
Write-Host " 3. Check the log: Get-Content '$DataDir\wsb-collector.log' -Tail 20"
$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
+5 -3
View File
@@ -15,11 +15,12 @@ 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:
1. Create a key for this server on the dashboard's **Admin** page, or on the dashboard host:
```bash
python manage.py create-key wsb-acme-dc01
```
2. Copy this folder to the server and run:
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"
```
@@ -35,7 +36,8 @@ Requires the Windows Server Backup feature with its PowerShell module
```
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.
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
+18 -111
View File
@@ -40,64 +40,23 @@ param(
$ErrorActionPreference = 'Stop'
$DataDir = Split-Path -Parent $ConfigPath
$LogPath = Join-Path $DataDir 'wsb-collector.log'
$StatePath = Join-Path $DataDir 'wsb-collector.state.json'
$MaxLogBytes = 5MB
# 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
$Invariant = [Globalization.CultureInfo]::InvariantCulture
# ---------- Helpers ----------
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 ($DryRun) { return }
if ((Test-Path $LogPath) -and (Get-Item $LogPath).Length -gt $MaxLogBytes) {
Move-Item $LogPath "$LogPath.1" -Force
}
Add-Content -Path $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.
function ConvertTo-IsoTime($Value) {
if ($null -eq $Value) { return $null }
$dt = [datetime]$Value
if ($dt.Year -le 1900) { return $null } # "not set" (DateTime.MinValue and friends)
return ([DateTimeOffset]$dt).ToString("yyyy-MM-dd'T'HH:mm:sszzz", $Invariant)
}
# First property of $Object, out of $Names, that exists and isn't empty. The WSB object model isn'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
}
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
@@ -288,49 +247,16 @@ function Get-NextRun {
}
}
function Send-Report($Body) {
$json = $Body | ConvertTo-Json -Depth 6 -Compress
if ($DryRun) {
Write-Host $json
return
}
# Send bytes: Windows PowerShell would otherwise encode a string body as Latin-1
$params = @{
Uri = "$($Config.ApiUrl.TrimEnd('/'))/api/v1/ingest"
Method = 'Post'
Headers = @{ Authorization = "Bearer $($Config.ApiKey)" }
ContentType = 'application/json; charset=utf-8'
Body = [Text.Encoding]::UTF8.GetBytes($json)
TimeoutSec = 30
}
Invoke-RestMethod @params | Out-Null
}
# ---------- Main ----------
try {
$Config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
foreach ($key in 'ApiUrl', 'ApiKey', 'Tenant') {
if (-not $Config.$key) { throw "Config $ConfigPath is missing '$key'" }
}
$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" }
if ($LookbackHours -le 0) {
$LookbackHours = if ($Config.LookbackHours) { [int]$Config.LookbackHours } else { 48 }
}
# Windows PowerShell 5.1 may default to TLS 1.0
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$LookbackHours = $Config.LookbackHours
Import-Wsb
# Run start time (ISO, as sent) -> Unix seconds, for runs already delivered in their final state
$sent = @{}
if (Test-Path $StatePath) {
$saved = Get-Content -Path $StatePath -Raw | ConvertFrom-Json
if ($saved) { foreach ($p in $saved.PSObject.Properties) { $sent[$p.Name] = [long]$p.Value } }
}
$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 {
@@ -342,7 +268,7 @@ try {
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 $sent.ContainsKey($_) } |
$pending = @($byStart.Keys | Sort-Object | Where-Object { -not (Test-RunSent ($_)) } |
ForEach-Object { $byStart[$_] })
$jobInfo = [ordered]@{
@@ -358,38 +284,19 @@ try {
}
$failures = 0
$runsSent = 0
try {
if ($pending.Count -eq 0) {
# Keep next_run_at fresh even when nothing new ran
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo })
Send-JobReport -Tenant $Config.Tenant -SourceSystem $SourceSystem -Job $jobInfo
}
foreach ($job in $pending) {
$run = ConvertTo-Run $job
Send-Report ([ordered]@{ tenant = $Config.Tenant; source_system = $SourceSystem; job = $jobInfo; run = $run })
$runsSent++
if ($run.status -ne 'running') {
$sent[$run.started_at] = ([DateTimeOffset](Get-JobTime $job 'StartTime')).ToUnixTimeSeconds()
}
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'
}
if (-not $DryRun) {
# Forget runs that have aged out of the lookback window (plus a day of slack)
$cutoff = [DateTimeOffset]::Now.AddHours(-$LookbackHours - 24).ToUnixTimeSeconds()
$keep = [ordered]@{}
foreach ($key in $sent.Keys) {
if ($sent[$key] -ge $cutoff) { $keep[$key] = $sent[$key] }
}
try {
$keep | ConvertTo-Json -Compress | Set-Content -Path $StatePath -Encoding UTF8
} catch {
Write-Log "Couldn't save state file: $(Get-ErrorText $_)" 'WARN'
}
}
Write-Log ("Reported {0} runs, {1} failures" -f $runsSent, $failures)
if ($failures) { exit 1 }
$exitCode = Complete-Collector -Failures $failures
if ($exitCode) { exit $exitCode }
+7 -2
View File
@@ -6,11 +6,13 @@
#
# . ./tests/MockWsb.ps1 # nothing running
# . ./tests/MockWsb.ps1 -WithRunningJob # a backup in progress
# . ./tests/MockWsb.ps1 -RunningJobFinished # that same backup, now finished
# ./Send-WsbBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
param([switch]$WithRunningJob)
param([switch]$WithRunningJob, [switch]$RunningJobFinished)
# Fixed to the top of the hour so repeated runs see the same jobs
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
# MOCK_NOW (ISO 8601, set by the test suite) pins the time everything is relative to
$now = if ($env:MOCK_NOW) { [datetime]$env:MOCK_NOW } else { 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) {
@@ -83,6 +85,9 @@ $global:MockWsbPrevious = @(
(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
)
if ($RunningJobFinished) {
$global:MockWsbPrevious += New-MockJob 'Backup' $now.AddMinutes(-10) $now.AddMinutes(-2) 'Completed' -Items @(New-VmList 10GB)
}
$global:MockWsbSummary = [pscustomobject]@{
NextBackupTime = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
+2
View File
@@ -2,3 +2,5 @@
# 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
# Microsoft 365 sign-in names (comma-separated) allowed into the admin pages (API key management)
BACKUP_DASH_ADMINS=[email protected]
+54 -8
View File
@@ -1,7 +1,7 @@
server {
listen 80;
server_name backups.example.com;
# Basic auth sends the password with every request — never serve this over plain HTTP
# Session cookies and API keys must never cross plain HTTP
return 301 https://$host$request_uri;
}
@@ -23,26 +23,72 @@ server {
add_header Referrer-Policy "no-referrer" always;
add_header Strict-Transport-Security "max-age=31536000" always;
# Dashboard users. Create with: htpasswd -B -c /etc/nginx/backup-dashboard.htpasswd <user>
# To move to Microsoft 365 sign-in later, replace these two lines with an auth_request to
# oauth2-proxy (provider=entra-id); the app itself doesn't need to change.
auth_basic "Backup Monitor";
auth_basic_user_file /etc/nginx/backup-dashboard.htpasswd;
# ---------- 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
# Collectors authenticate with their own API key (Authorization: Bearer …), checked by the app,
# so no Microsoft sign-in here
location = /api/v1/ingest {
auth_basic off;
client_max_body_size 256k;
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
+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
+103
View File
@@ -0,0 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>API keys · Backup Monitor</title>
<link rel="stylesheet" href="/css/styles.css">
<script type="module" src="/js/admin.js"></script>
</head>
<body>
<header class="topbar">
<div class="topbar__nav">
<a id="back-link" class="btn" href="/">← Dashboard</a>
<h1 class="topbar__title">API keys</h1>
</div>
<div class="topbar__meta">
<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 container--narrow">
<div id="error-banner" class="banner banner--error" role="alert" hidden></div>
<p id="not-admin" class="banner banner--info" hidden>
Your account isn't an admin. Admins are the sign-in names listed in <code>BACKUP_DASH_ADMINS</code>
in the server's settings file.
</p>
<div id="admin-content" hidden>
<section class="panel" aria-labelledby="new-key-title">
<h2 id="new-key-title" class="panel__title">New key</h2>
<p class="muted">Create one key per collector install, so each can be revoked on its own.</p>
<form id="new-key-form" class="new-key">
<div class="field">
<label for="key-name">Name</label>
<span id="key-name-hint" class="hint">For example <code>veeam-acme</code> or <code>mars-acme-fs01</code>:
letters, digits, <code>.</code>, <code>_</code> and <code>-</code>.</span>
<input id="key-name" name="name" class="input" required maxlength="64"
pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" autocomplete="off" spellcheck="false"
aria-describedby="key-name-hint" aria-errormessage="key-name-error">
<div id="key-name-error" class="error-msg">
Use up to 64 letters, digits, <code>.</code>, <code>_</code> or <code>-</code>, starting with a letter or digit.
</div>
</div>
<button id="create-btn" class="btn btn--primary" type="submit">Create key</button>
<p id="create-error" class="form-error" role="alert" hidden></p>
</form>
<div id="new-key-result" class="secret" hidden>
<h3 id="new-key-result-title" class="secret__title" tabindex="-1">Copy the key for <span id="new-key-name"></span> now</h3>
<p>It won't be shown again: the dashboard only keeps a hash of it. Paste it when the collector's
installer asks for the API key.</p>
<div class="secret__row">
<code id="new-key-secret" class="secret__value"></code>
<button id="copy-btn" class="btn" type="button">Copy</button>
</div>
<button id="done-btn" class="btn" type="button">Done, hide it</button>
</div>
</section>
<section aria-labelledby="keys-title">
<h2 id="keys-title" class="panel__title" tabindex="-1">Keys</h2>
<div class="table-wrap">
<table class="jobs keys">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Status</th>
<th scope="col">Last used</th>
<th scope="col">Created</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody id="keys-body"></tbody>
</table>
<p id="keys-empty" class="empty" hidden>No keys yet. Create one above for each collector.</p>
</div>
</section>
</div>
<!-- Announces results (created, copied, revoked) to screen readers -->
<p id="status" class="visually-hidden" role="status"></p>
</main>
<dialog id="revoke-dialog" class="modal modal--small" aria-labelledby="revoke-title">
<form method="dialog">
<div class="modal__header">
<h2 id="revoke-title" class="modal__title">Revoke <span id="revoke-name"></span>?</h2>
</div>
<div class="modal__body">
<p>Collectors using this key will be refused from their next run. This can't be undone: to
reconnect them, create a new key and re-run their installer.</p>
<div class="dialog-actions">
<button class="btn" value="cancel" autofocus>Cancel</button>
<button class="btn btn--danger" value="revoke">Revoke key</button>
</div>
</div>
</form>
</dialog>
</body>
</html>
+184
View File
@@ -71,6 +71,12 @@ body {
}
.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;
@@ -339,3 +345,181 @@ button.badge:hover { filter: brightness(0.95); }
.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; }
/* ---------- Admin page ---------- */
.container--narrow { max-width: 1100px; }
.topbar__nav { display: flex; align-items: center; gap: 1rem; }
.banner--info { background: var(--surface-alt); color: var(--text); border-color: var(--border); }
.panel {
margin-bottom: 1.5rem;
padding: 1rem 1.25rem 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.panel__title { font-size: 1.05rem; margin: 0 0 0.25rem; }
section > .panel__title { margin: 0 0 0.75rem; }
.panel__title:focus { outline: none; } /* focused from script after a change, not by the user */
.panel p { margin: 0 0 0.75rem; }
code {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.9em;
overflow-wrap: anywhere;
}
/* New key form: label, hint above the input (so autocomplete can't cover it), error below */
.new-key { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 0.75rem; }
.field { display: flex; flex-direction: column; gap: 0.25rem; flex: 1 1 22rem; max-width: 30rem; }
.field label { font-weight: 600; }
.hint { font-size: 0.85rem; color: var(--muted); }
.field .input { width: 100%; }
.error-msg { display: none; font-size: 0.85rem; color: var(--failed-fg); }
/* Only after the user has interacted with the field (or tried to submit) */
.field .input:user-invalid { border-color: var(--failed-bd); background: var(--failed-bg); }
.field .input:user-invalid + .error-msg { display: block; }
.form-error { flex-basis: 100%; margin: 0; color: var(--failed-fg); }
.btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
.btn--primary:hover { filter: brightness(1.08); border-color: var(--accent); }
.btn--danger { background: var(--failed-bd); border-color: var(--failed-bd); color: #fff; font-weight: 600; }
.btn--danger-outline { color: var(--failed-fg); border-color: var(--failed-bd); background: transparent; }
.btn--danger-outline:hover { background: var(--failed-bg); border-color: var(--failed-bd); }
/* The one-time key */
.secret {
margin-top: 1rem;
padding: 1rem;
border: 1px solid var(--warning-bd);
border-left-width: 4px;
border-radius: 6px;
background: var(--warning-bg);
}
.secret__title { margin: 0 0 0.5rem; font-size: 1rem; }
.secret__title:focus { outline: none; }
.secret__row { display: flex; gap: 0.5rem; align-items: stretch; margin-bottom: 0.75rem; flex-wrap: wrap; }
.secret__value {
flex: 1 1 20rem;
padding: 0.45rem 0.75rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
user-select: all; /* one click selects the whole key */
}
/* Key table */
.keys td { vertical-align: top; }
.keys td .badge { margin-bottom: 0.25rem; }
.keys td > .time__rel { display: block; }
.keys .actions { text-align: right; width: 1%; }
.keys tr.is-revoked td { color: var(--muted); }
.keys tr.is-revoked code { text-decoration: line-through; }
.stale-note { display: block; font-size: 0.8rem; font-weight: 600; color: var(--warning-fg); }
/* Confirm dialog */
.modal--small { width: min(460px, calc(100vw - 2rem)); }
.modal--small .modal__body p { margin-top: 0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1rem; }
/* ---------- Report page ---------- */
.report-controls {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem 1rem;
margin-bottom: 1.25rem;
}
.report-controls__field { display: flex; flex-direction: column; gap: 0.25rem; }
.report-controls__field label { font-weight: 600; font-size: 0.9rem; }
.report-controls__field .input { width: auto; min-width: 14rem; }
.report-controls__actions { display: flex; gap: 0.5rem; margin-left: auto; }
.btn[aria-disabled="true"] { opacity: 0.55; pointer-events: none; }
.report__header { margin-bottom: 1rem; }
.report__title { margin: 0; font-size: 1.4rem; }
.report__period { margin: 0.25rem 0 0; color: var(--muted); }
.report__section-title { font-size: 1.05rem; margin: 1.75rem 0 0.75rem; }
.report__footer { margin-top: 1.5rem; font-size: 0.85rem; }
.report-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 0.75rem;
margin: 0;
}
.report-summary__item {
padding: 0.75rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-left-width: 4px;
border-radius: var(--radius);
}
.report-summary__item dt { font-size: 0.85rem; color: var(--muted); }
.report-summary__item dd { margin: 0; font-size: 1.5rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.report-summary__item--warning { border-left-color: var(--warning-bd); }
.report-summary__item--failed { border-left-color: var(--failed-bd); }
/* Row headers (job names) are <th> for accessibility, but shouldn't look like the column headings */
.report-table tbody th {
background: none; color: var(--text); font-size: inherit; font-weight: 600;
text-transform: none; letter-spacing: normal;
}
.report-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.report-table td > .time__rel { display: block; }
.report-table .badge { font-size: 0.75rem; padding: 0.1rem 0.5rem; }
.report-alert { color: var(--failed-fg); font-weight: 600; }
.report-message { white-space: pre-wrap; overflow-wrap: anywhere; min-width: 20rem; font-size: 0.85rem; }
.nowrap { white-space: nowrap; }
.report-legend {
display: flex; flex-wrap: wrap; gap: 0.25rem 1rem;
margin: 0 0 0.75rem; padding: 0; list-style: none; font-size: 0.85rem; color: var(--muted);
}
.report-legend li { display: flex; align-items: center; gap: 0.35rem; }
.report-legend .day { display: inline-block; width: 0.9rem; height: 0.9rem; }
.report-daily__job { margin-bottom: 0.75rem; }
.report-daily__name { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem; }
/* One cell per day of the report's period */
.report-strip { grid-template-columns: repeat(var(--days, 30), 1fr); gap: 2px; }
.report-strip .day { display: block; height: 18px; cursor: default; }
/* Before the dashboard knew of the job: outlined only, so it doesn't read as a missed backup */
.day--untracked { background: transparent; border: 1px dashed var(--muted); opacity: 0.5; }
/* ---------- Printing ---------- */
@media print {
/* Always print light, whatever the screen's theme (same values as the light tokens above) */
:root {
color-scheme: light;
--bg: #ffffff;
--surface: #ffffff;
--surface-alt: #f0f2f5;
--border: #c9ced6;
--text: #1f2328;
--muted: #57606a;
--accent: #0969da;
--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;
--shadow: none;
font-size: 11pt;
}
@page { margin: 14mm; }
body { background: #fff; color: var(--text); }
.no-print, .topbar, .banner { display: none !important; }
.container { max-width: none; padding: 0; }
.table-wrap { overflow: visible; box-shadow: none; }
.jobs th, .jobs td { white-space: normal; padding: 0.35rem 0.5rem; }
.jobs tbody tr:hover { background: none; }
.report-message { min-width: 0; }
/* Keep things whole across page breaks */
tr, .report-daily__job, .report-summary__item, .report-legend { break-inside: avoid; }
.report__section-title { break-after: avoid; }
thead { display: table-header-group; } /* repeat column headings on each page */
/* Browsers drop backgrounds when printing unless told they carry meaning */
.day, .badge, .report-summary__item { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
}
+7
View File
@@ -14,6 +14,13 @@
<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>
<a id="reports-link" class="btn" href="/report.html">Reports</a>
<a id="admin-link" class="btn" href="/admin.html" hidden>Admin</a>
<!-- 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>
+228
View File
@@ -0,0 +1,228 @@
// Admin page: create, list and revoke the API keys collectors use.
import { getApi } from './api.js';
import { el } from './dom.js';
import { parseDate, formatDateTime, formatRelative } from './format.js';
import { showSignedInUser } from './signed-in.js';
const api = await getApi();
// A collector runs hourly; an active key quiet for longer than this probably has a problem
const STALE_HOURS = 24;
const $ = (sel) => document.querySelector(sel);
const dom = {
backLink: $('#back-link'),
signedIn: $('#signed-in'),
signedInEmail: $('#signed-in-email'),
signOut: $('#sign-out'),
errorBanner: $('#error-banner'),
notAdmin: $('#not-admin'),
content: $('#admin-content'),
form: $('#new-key-form'),
name: $('#key-name'),
createBtn: $('#create-btn'),
createError: $('#create-error'),
result: $('#new-key-result'),
resultTitle: $('#new-key-result-title'),
resultName: $('#new-key-name'),
secret: $('#new-key-secret'),
copyBtn: $('#copy-btn'),
doneBtn: $('#done-btn'),
keysTitle: $('#keys-title'),
tbody: $('#keys-body'),
empty: $('#keys-empty'),
status: $('#status'),
dialog: $('#revoke-dialog'),
revokeName: $('#revoke-name'),
};
let keys = [];
let pendingRevoke = null;
// ---------- Feedback ----------
// Screen-reader announcement. Cleared first so repeating the same message is announced again.
// (A timer rather than requestAnimationFrame, which doesn't run while the tab is in the background.)
function announce(message) {
dom.status.textContent = '';
setTimeout(() => { dom.status.textContent = message; }, 50);
}
function showError(message) {
dom.errorBanner.textContent = message;
dom.errorBanner.hidden = false;
}
// ---------- Key table ----------
function timeBlock(date, ...extra) {
return el('time', { class: 'time', datetime: date.toISOString(), title: date.toString() },
el('span', {}, formatDateTime(date)),
el('span', { class: 'time__rel' }, formatRelative(date), ...extra));
}
function statusCell(key) {
if (key.revoked_at) {
return el('td', {},
el('span', { class: 'badge badge--unknown' }, 'Revoked'),
el('span', { class: 'time__rel' }, formatDateTime(parseDate(key.revoked_at)),
key.revoked_by ? ` by ${key.revoked_by}` : null));
}
return el('td', {}, el('span', { class: 'badge badge--success' }, 'Active'));
}
function lastUsedCell(key) {
const lastUsed = parseDate(key.last_used_at);
if (!lastUsed) {
return el('td', {}, el('span', { class: 'muted' }, key.revoked_at ? 'Never' : 'Never: not installed yet?'));
}
const stale = !key.revoked_at && Date.now() - lastUsed.getTime() > STALE_HOURS * 3_600_000;
return el('td', { class: stale ? 'is-stale' : null },
timeBlock(lastUsed, key.last_used_tenant ? ` · ${key.last_used_tenant}` : null),
stale ? el('span', { class: 'stale-note' }, `Quiet for over ${STALE_HOURS} hours`) : null);
}
function createdCell(key) {
const created = parseDate(key.created_at);
return el('td', {},
created ? timeBlock(created) : el('span', { class: 'muted' }, 'Unknown'),
key.created_by ? el('span', { class: 'time__rel' }, `by ${key.created_by}`) : null);
}
function renderKeys() {
dom.tbody.replaceChildren(...keys.map((key) => el('tr', { class: key.revoked_at ? 'is-revoked' : null },
el('td', {}, el('code', {}, key.name)),
statusCell(key),
lastUsedCell(key),
createdCell(key),
el('td', { class: 'actions' }, key.revoked_at ? null : el('button', {
type: 'button',
class: 'btn btn--danger-outline',
'aria-haspopup': 'dialog',
'aria-label': `Revoke ${key.name}`,
dataset: { keyId: String(key.id) },
}, 'Revoke')))));
dom.empty.hidden = keys.length > 0;
}
async function loadKeys() {
try {
({ keys } = await api.listKeys());
dom.errorBanner.hidden = true;
renderKeys();
} catch (err) {
showError(`Couldn't load the keys: ${err.message}`);
}
}
// ---------- Creating a key ----------
// Keep aria-invalid in step with :user-invalid (the browser doesn't sync them)
function syncInvalid() {
dom.name.setAttribute('aria-invalid', dom.name.matches(':user-invalid') ? 'true' : 'false');
}
dom.name.addEventListener('blur', syncInvalid);
dom.name.addEventListener('invalid', syncInvalid); // a submit attempt with a bad name
dom.name.addEventListener('input', () => { if (dom.name.hasAttribute('aria-invalid')) syncInvalid(); });
function showSecret(name, secret) {
dom.resultName.textContent = name;
dom.secret.textContent = secret;
dom.copyBtn.textContent = 'Copy';
dom.result.hidden = false;
dom.resultTitle.focus();
}
function hideSecret() {
dom.result.hidden = true;
dom.secret.textContent = '';
}
// Only fires once the browser's own validation (required, pattern) has passed
dom.form.addEventListener('submit', async (e) => {
e.preventDefault();
dom.createBtn.disabled = true;
dom.createError.hidden = true;
hideSecret();
try {
const { key, secret } = await api.createKey(dom.name.value.trim());
dom.form.reset();
dom.name.removeAttribute('aria-invalid');
showSecret(key.name, secret);
announce(`Key ${key.name} created. Copy it now: it won't be shown again.`);
await loadKeys();
} catch (err) {
dom.createError.textContent = err.message;
dom.createError.hidden = false;
} finally {
dom.createBtn.disabled = false;
}
});
dom.copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(dom.secret.textContent);
dom.copyBtn.textContent = 'Copied';
announce('Key copied to the clipboard.');
} catch {
// Clipboard access can be refused (permissions, non-secure context): select it for Ctrl+C instead
getSelection().selectAllChildren(dom.secret);
dom.copyBtn.textContent = 'Selected: press Ctrl+C';
announce('Copy blocked by the browser. The key is selected: press Ctrl+C to copy it.');
}
});
dom.doneBtn.addEventListener('click', () => {
hideSecret();
dom.name.focus();
});
// ---------- Revoking a key ----------
dom.tbody.addEventListener('click', (e) => {
const button = e.target.closest('button[data-key-id]');
if (!button) return;
pendingRevoke = keys.find((k) => String(k.id) === button.dataset.keyId) ?? null;
if (!pendingRevoke) return;
dom.revokeName.textContent = pendingRevoke.name;
dom.dialog.returnValue = '';
dom.dialog.showModal();
});
// returnValue is the clicked button's value; Esc leaves it empty, which cancels
dom.dialog.addEventListener('close', async () => {
const key = pendingRevoke;
pendingRevoke = null;
if (dom.dialog.returnValue !== 'revoke' || !key) return;
try {
await api.revokeKey(key.id);
announce(`Key ${key.name} revoked.`);
await loadKeys();
} catch (err) {
showError(`Couldn't revoke ${key.name}: ${err.message}`);
}
// Its Revoke button is gone, so focus would otherwise fall back to the page
dom.keysTitle.focus();
});
// ---------- Start ----------
async function main() {
dom.backLink.href = `/${location.search}`; // keeps ?mock=1
let me;
try {
me = await api.getMe();
} catch (err) {
showError(`Couldn't check who's signed in: ${err.message}`);
return;
}
showSignedInUser(me, dom);
if (!me?.is_admin) {
dom.notAdmin.hidden = false;
return;
}
dom.content.hidden = false;
await loadKeys();
}
main();
+60 -4
View File
@@ -8,18 +8,41 @@ export class ApiError extends Error {
}
}
async function request(path) {
// The API's explanation from an error response: FastAPI's {"detail": "..."}, or for validation
// errors {"detail": [{"msg": "..."}, ...]}
async function errorDetail(res) {
try {
const { detail } = await res.json();
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) return detail.map((d) => d.msg).filter(Boolean).join('; ');
} catch {
// not JSON: fall back to the status code
}
return null;
}
async function request(path, { method = 'GET', body } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
const headers = { Accept: 'application/json' };
if (method !== 'GET') {
// Required by the API for changes: another site's page can't add it to a request here
headers['X-Requested-By'] = 'backup-dashboard';
}
if (body !== undefined) headers['Content-Type'] = 'application/json';
try {
const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin', // ready for cookie/session auth behind nginx
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
credentials: 'same-origin', // the sign-in session cookie
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);
if (!res.ok) {
throw new ApiError((await errorDetail(res)) ?? `Server returned HTTP ${res.status}`, res.status);
}
return await res.json();
} catch (err) {
if (err.name === 'AbortError') throw new ApiError('Request timed out');
@@ -49,6 +72,39 @@ const httpApi = {
getRun(id) {
return request(`/runs/${encodeURIComponent(id)}`);
},
/** GET /me → { email, name, is_admin } of the Microsoft 365 user; email/name null without sign-in */
getMe() {
return request('/me');
},
// ----- Reports -----
/** GET /tenants → { tenants: [{ name, jobs }] } */
listTenants() {
return request('/tenants');
},
/** GET /reports/tenant → { tenant, days, since, until, generated_at, jobs: [job + runs] } */
tenantReport(tenant, days) {
return request(`/reports/tenant?tenant=${encodeURIComponent(tenant)}&days=${encodeURIComponent(days)}`);
},
/** Link to the CSV of every run in the period, with times in time zone `tz` */
async tenantReportCsvUrl(tenant, days, tz) {
const params = new URLSearchParams({ tenant, days: String(days), tz });
return `${CONFIG.apiBaseUrl}/reports/tenant.csv?${params}`;
},
// ----- Admin (403 for non-admins) -----
/** GET /admin/keys → { keys: ApiKey[] } (active first) */
listKeys() {
return request('/admin/keys');
},
/** POST /admin/keys → { key: ApiKey, secret } — the secret is only ever returned here */
createKey(name) {
return request('/admin/keys', { method: 'POST', body: { name } });
},
/** POST /admin/keys/:id/revoke → { key: ApiKey } */
revokeKey(id) {
return request(`/admin/keys/${encodeURIComponent(id)}/revoke`, { method: 'POST' });
},
};
// Mock is loaded lazily so it never ships in the production request path.
+25 -87
View File
@@ -1,5 +1,11 @@
import { CONFIG } from './config.js';
import { getApi } from './api.js';
import { el } from './dom.js';
import { showSignedInUser } from './signed-in.js';
import { STATUS, normalizeStatus, statusBadge } from './status.js';
import {
localDayKey, dayLabelFmt, dayTickFmt, normalizeRun, buildDays, describeCounts,
} from './history.js';
import {
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
} from './format.js';
@@ -7,20 +13,6 @@ import {
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 {
@@ -56,6 +48,11 @@ const dom = {
empty: $('#empty-state'),
search: $('#search'),
refreshBtn: $('#refresh-btn'),
signedIn: $('#signed-in'),
signedInEmail: $('#signed-in-email'),
signOut: $('#sign-out'),
adminLink: $('#admin-link'),
reportsLink: $('#reports-link'),
lastUpdated: $('#last-updated'),
errorBanner: $('#error-banner'),
dialog: $('#detail-dialog'),
@@ -64,34 +61,7 @@ const dom = {
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);
@@ -238,51 +208,6 @@ const modal = {
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)];
@@ -295,7 +220,8 @@ function renderSummarySection() {
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;
const company = detail?.company_name ?? job.company;
dom.detailSubtitle.replaceChildren(el('a', { href: reportUrl(company), title: `Backup report for ${company}` }, company));
return el('section', {},
el('div', { class: 'detail-status' },
@@ -524,5 +450,17 @@ dom.search.addEventListener('input', () => {
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
// A company's report page (keeps ?mock=1)
function reportUrl(company) {
const params = new URLSearchParams({ tenant: company, days: '30' });
if (CONFIG.useMock) params.set('mock', '1');
return `/report.html?${params}`;
}
// ---------- Boot ----------
dom.reportsLink.href = `/report.html${CONFIG.useMock ? '?mock=1' : ''}`;
api.getMe().then(
(me) => showSignedInUser(me, dom),
(err) => console.error(err), // not worth a banner: the jobs request will surface real problems
);
refresh().then(schedulePoll);
+4
View File
@@ -12,4 +12,8 @@ export const CONFIG = Object.freeze({
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'),
});
+12
View File
@@ -0,0 +1,12 @@
// Tiny element builder. All text goes through append() → text nodes, so API data can never inject HTML.
export 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;
}
+48
View File
@@ -0,0 +1,48 @@
// Run history: runs grouped into days (local time), as shown in the dashboard's job detail and in reports.
import { parseDate } from './format.js';
import { STATUS, normalizeStatus } from './status.js';
// Colour of a day cell: the worst thing that happened that day
export const DAY_SEVERITY = ['failed', 'warning', 'unknown', 'running', 'success'];
const pad2 = (n) => String(n).padStart(2, '0');
export const localDayKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
export const dayLabelFmt = new Intl.DateTimeFormat(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
export const dayTickFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
export 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,
};
}
export 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;
}
export function describeCounts(counts) {
return DAY_SEVERITY.filter((s) => counts[s])
.map((s) => `${counts[s]} ${STATUS[s].label.toLowerCase()}`)
.join(', ');
}
+80
View File
@@ -96,6 +96,18 @@ for (const job of JOBS) {
}
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
// Sample API keys for the admin page: one healthy, one gone quiet, one never used, one revoked
const KEYS = [
{ id: 1, name: 'veeam-acme', created_at: iso(-40 * DAY), created_by: '[email protected]',
last_used_at: iso(-12 * MIN), last_used_tenant: 'Acme Corp', revoked_at: null, revoked_by: null },
{ id: 2, name: 'mars-bluewater-fs01', created_at: iso(-20 * DAY), created_by: 'manage.py (backupdash)',
last_used_at: iso(-3 * DAY), last_used_tenant: 'Bluewater Dental', revoked_at: null, revoked_by: null },
{ id: 3, name: 'wsb-northfield-dc01', created_at: iso(-2 * HR), created_by: '[email protected]',
last_used_at: null, last_used_tenant: null, revoked_at: null, revoked_by: null },
{ id: 4, name: 'veeam-old-server', created_at: iso(-90 * DAY), created_by: 'manage.py (backupdash)',
last_used_at: iso(-31 * DAY), last_used_tenant: 'Acme Corp', revoked_at: iso(-30 * DAY), revoked_by: '[email protected]' },
];
const summary = ({ details, every_h, last_status, duration_seconds, size_bytes, ...rest }) => rest;
export const mockApi = {
@@ -126,4 +138,72 @@ export const mockApi = {
}
throw new Error('Run not found');
},
async getMe() {
// No sign-in in mock mode, but show the admin pages so they can be tried out
return { email: null, name: null, is_admin: true };
},
async listTenants() {
await delay(150);
const counts = new Map();
for (const j of JOBS) counts.set(j.company_name, (counts.get(j.company_name) ?? 0) + 1);
return { tenants: [...counts].map(([name, jobs]) => ({ name, jobs })).sort((a, b) => a.name.localeCompare(b.name)) };
},
async tenantReport(tenant, days) {
await delay(250);
const jobs = JOBS.filter((j) => j.company_name.toLowerCase() === tenant.toLowerCase());
if (!jobs.length) throw new Error(`No company named '${tenant}'`);
const since = now - days * DAY;
return {
tenant: jobs[0].company_name, days,
since: new Date(since).toISOString(), until: new Date(now).toISOString(), generated_at: new Date(now).toISOString(),
jobs: jobs.map(({ details, every_h, ...job }) => ({
...job,
// Earliest run on record, as the API reports it
tracked_since: (RUNS.get(String(job.id)) ?? []).map((r) => r.started_at).sort()[0] ?? new Date(now).toISOString(),
runs: (RUNS.get(String(job.id)) ?? [])
.filter((r) => Date.parse(r.started_at) >= since)
.map(({ id, details: _d, message, ...r }) => ({ ...r, message: ['warning', 'failed'].includes(r.status) ? message : null }))
.reverse(), // oldest first, like the API
})),
};
},
// No server in mock mode: build the CSV here and link to it as a blob
async tenantReportCsvUrl(tenant, days, tz) {
const report = await this.tenantReport(tenant, days);
const quoteCell = (v) => `"${String(v ?? '').replaceAll('"', '""')}"`;
// Times stay in UTC here (the real API converts them to `tz`)
const rows = [['Company', 'Job', 'Source', 'Started (UTC)', 'Finished (UTC)', 'Status', 'Message']];
for (const job of report.jobs) {
for (const run of job.runs) {
rows.push([report.tenant, job.job_name, job.source_system, run.started_at, run.finished_at ?? '', run.status, run.message ?? '']);
}
}
const csv = rows.map((r) => r.map(quoteCell).join(',')).join('\r\n');
return URL.createObjectURL(new Blob(['\ufeff' + csv], { type: 'text/csv' }));
},
async listKeys() {
await delay(200);
return { keys: [...KEYS].sort((a, b) => (!!a.revoked_at - !!b.revoked_at) || a.name.localeCompare(b.name)) };
},
async createKey(name) {
await delay(300);
if (KEYS.some((k) => k.name === name)) {
throw new Error(`A key named '${name}' already exists (revoked keys keep their names)`);
}
const key = { id: KEYS.length + 1, name, created_at: new Date().toISOString(), created_by: '[email protected]',
last_used_at: null, last_used_tenant: null, revoked_at: null, revoked_by: null };
KEYS.push(key);
const random = crypto.getRandomValues(new Uint8Array(32));
return { key, secret: `bdk_mock_${btoa(String.fromCharCode(...random)).replace(/[+/=]/g, '')}` };
},
async revokeKey(id) {
await delay(200);
const key = KEYS.find((k) => k.id === id);
if (!key) throw new Error('Key not found');
key.revoked_at ??= new Date().toISOString();
key.revoked_by ??= '[email protected]';
return { key };
},
};
+248
View File
@@ -0,0 +1,248 @@
// Per-company backup report: summary, per-job results, day-by-day strips and every warning and
// failure for a period. Printable (Print / Save as PDF) and downloadable as CSV.
import { CONFIG } from './config.js';
import { getApi } from './api.js';
import { el } from './dom.js';
import { parseDate, formatDateTime } from './format.js';
import { buildDays, describeCounts, dayLabelFmt, normalizeRun } from './history.js';
import { showSignedInUser } from './signed-in.js';
import { normalizeStatus, statusBadge } from './status.js';
const api = await getApi();
// Report messages are trimmed to keep a printed report readable; the CSV has them in full
const MAX_MESSAGE_CHARS = 400;
const TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
const dateFmt = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
const percentFmt = new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 1 });
const $ = (sel) => document.querySelector(sel);
const dom = {
backLink: $('#back-link'),
signedIn: $('#signed-in'),
signedInEmail: $('#signed-in-email'),
signOut: $('#sign-out'),
tenant: $('#tenant'),
days: $('#days'),
printBtn: $('#print-btn'),
csvLink: $('#csv-link'),
errorBanner: $('#error-banner'),
chooseHint: $('#choose-hint'),
loading: $('#loading'),
report: $('#report'),
title: $('#report-title'),
period: $('#report-period'),
summary: $('#report-summary'),
jobsBody: $('#jobs-body'),
daily: $('#daily'),
issuesBody: $('#issues-body'),
issuesWrap: $('#issues-wrap'),
issuesNone: $('#issues-none'),
footer: $('#report-footer'),
};
let requestId = 0; // a slow response for one company must not overwrite a later one
// ---------- Numbers ----------
function summarize(runs) {
const counts = { success: 0, warning: 0, failed: 0, running: 0, unknown: 0 };
for (const run of runs) counts[run.status]++;
const finished = runs.length - counts.running;
const lastFinished = runs.filter((r) => r.finishedAt).at(-1) ?? null;
const lastSuccess = runs.filter((r) => r.status === 'success').at(-1) ?? null;
return {
counts,
total: runs.length,
finished,
// Of finished runs only: one still in progress hasn't succeeded or failed yet
rate: finished ? counts.success / finished : null,
lastFinished,
lastSuccess,
};
}
const isOverdue = (job) => {
const next = parseDate(job.next_run_at);
return normalizeStatus(job.status) !== 'running' && !!next
&& next.getTime() + CONFIG.overdueGraceMinutes * 60_000 < Date.now();
};
// ---------- Rendering ----------
function renderSummary(jobs) {
const all = summarize(jobs.flatMap((j) => j.runs));
const overdue = jobs.filter((j) => isOverdue(j.job)).length;
const item = (label, value, modifier = '') => el('div', { class: `report-summary__item ${modifier}` },
el('dt', {}, label), el('dd', {}, value));
dom.summary.replaceChildren(
item('Jobs', String(jobs.length)),
item('Backup runs', String(all.total)),
item('Success rate', all.rate === null ? '—' : percentFmt.format(all.rate)),
item('Warnings', String(all.counts.warning), all.counts.warning ? 'report-summary__item--warning' : ''),
item('Failures', String(all.counts.failed), all.counts.failed ? 'report-summary__item--failed' : ''),
item('Overdue now', String(overdue), overdue ? 'report-summary__item--failed' : ''),
);
}
function renderJobs(jobs, days) {
dom.jobsBody.replaceChildren(...jobs.map(({ job, runs, stats }) => {
const last = stats.lastFinished;
return el('tr', {},
el('th', { scope: 'row' }, job.job_name),
el('td', {}, job.source_system),
el('td', { class: 'num' }, String(stats.total), stats.counts.running ? el('span', { class: 'time__rel' }, `${stats.counts.running} in progress`) : null),
el('td', { class: 'num' }, String(stats.counts.success)),
el('td', { class: 'num' }, String(stats.counts.warning)),
el('td', { class: 'num' }, String(stats.counts.failed)),
el('td', { class: 'num' }, stats.rate === null ? '—' : percentFmt.format(stats.rate)),
el('td', {}, last
? [statusBadge(last.status), el('span', { class: 'time__rel' }, formatDateTime(last.finishedAt))]
: el('span', { class: 'muted' }, 'No finished runs')),
el('td', {}, stats.lastSuccess
? formatDateTime(stats.lastSuccess.finishedAt)
: el('span', { class: 'report-alert' }, `None in the last ${days} days`)));
}));
}
function renderDaily(jobs, days) {
dom.daily.replaceChildren(...jobs.map(({ job, runs }) => {
const cells = buildDays(runs, days);
// Days before the dashboard knew of the job aren't missed backups, just unmonitored ones
const tracked = parseDate(job.tracked_since);
if (tracked) {
const firstDay = new Date(tracked);
firstDay.setHours(0, 0, 0, 0);
for (const d of cells) if (d.date < firstDay && !d.total) d.status = 'untracked';
}
const tally = {};
for (const d of cells) tally[d.status] = (tally[d.status] ?? 0) + 1;
const description = `${job.job_name}, ${days} days: ${tally.success ?? 0} successful, ${tally.warning ?? 0} with warnings, `
+ `${tally.failed ?? 0} failed, ${tally.none ?? 0} without a backup`
+ (tally.untracked ? `, ${tally.untracked} before monitoring began` : '');
return el('div', { class: 'report-daily__job' },
el('div', { class: 'report-daily__name' }, job.job_name),
// Coloured cells are visual only; the label gives screen readers the same picture
el('div', { class: 'day-strip report-strip', role: 'img', 'aria-label': description, style: `--days: ${days}` },
cells.map((d) => el('span', {
class: `day day--${d.status}`,
title: `${dayLabelFmt.format(d.date)}: ${d.total ? describeCounts(d.counts) : d.status === 'untracked' ? 'not monitored yet' : 'no backup'}`,
}))));
}));
}
function renderIssues(jobs) {
const issues = jobs
.flatMap(({ job, runs }) => runs
.filter((r) => r.status === 'warning' || r.status === 'failed')
.map((r) => ({ job, run: r })))
.sort((a, b) => b.run.startedAt - a.run.startedAt); // newest first
dom.issuesBody.replaceChildren(...issues.map(({ job, run }) => {
const message = (run.message ?? '').trim() || 'No details reported.';
const shown = message.length > MAX_MESSAGE_CHARS ? `${message.slice(0, MAX_MESSAGE_CHARS)}…` : message;
return el('tr', {},
el('td', { class: 'nowrap' }, formatDateTime(run.startedAt)),
el('th', { scope: 'row' }, job.job_name),
el('td', {}, statusBadge(run.status)),
el('td', { class: 'report-message' }, shown));
}));
dom.issuesNone.hidden = issues.length > 0;
dom.issuesWrap.hidden = issues.length === 0;
}
function render(report) {
const days = report.days;
const jobs = report.jobs.map((job) => {
const runs = job.runs.map((r) => ({ ...normalizeRun({ id: '', ...r }), message: r.message }))
.filter((r) => r.startedAt);
return { job, runs, stats: summarize(runs) };
});
const since = parseDate(report.since);
const until = parseDate(report.until);
dom.title.textContent = `Backup report: ${report.tenant}`;
dom.period.textContent = `${dateFmt.format(since)} – ${dateFmt.format(until)} (last ${days} days)`;
document.title = `Backup report: ${report.tenant} · Backup Monitor`;
renderSummary(jobs);
renderJobs(jobs, days);
renderDaily(jobs, days);
renderIssues(jobs);
dom.footer.textContent = `Generated ${formatDateTime(parseDate(report.generated_at))}. Times are in ${TIME_ZONE}. `
+ 'Success rate counts finished runs only. The CSV export lists every run with its full details.';
dom.report.hidden = false;
}
// ---------- Loading ----------
function setActionsEnabled(enabled) {
dom.printBtn.disabled = !enabled;
if (!enabled) {
dom.csvLink.removeAttribute('href');
dom.csvLink.setAttribute('aria-disabled', 'true');
}
}
async function load() {
const tenant = dom.tenant.value;
const days = Number(dom.days.value);
// Shareable, bookmarkable URL (keeps ?mock=1)
const params = new URLSearchParams(location.search);
if (tenant) params.set('tenant', tenant); else params.delete('tenant');
params.set('days', String(days));
history.replaceState(null, '', `?${params}`);
dom.errorBanner.hidden = true;
setActionsEnabled(false);
if (!tenant) {
dom.report.hidden = true;
dom.chooseHint.hidden = false;
return;
}
dom.chooseHint.hidden = true;
dom.loading.hidden = false;
const id = ++requestId;
try {
const report = await api.tenantReport(tenant, days);
if (id !== requestId) return;
render(report);
dom.csvLink.href = await api.tenantReportCsvUrl(report.tenant, days, TIME_ZONE);
dom.csvLink.download = `backup-report-${report.tenant.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.csv`;
dom.csvLink.removeAttribute('aria-disabled');
dom.printBtn.disabled = false;
} catch (err) {
if (id !== requestId) return;
dom.report.hidden = true;
dom.errorBanner.textContent = `Couldn't load the report: ${err.message}`;
dom.errorBanner.hidden = false;
} finally {
if (id === requestId) dom.loading.hidden = true;
}
}
dom.tenant.addEventListener('change', load);
dom.days.addEventListener('change', load);
dom.printBtn.addEventListener('click', () => window.print());
async function main() {
dom.backLink.href = `/${location.search.includes('mock') ? '?mock=1' : ''}`;
api.getMe().then((me) => showSignedInUser(me, dom), () => {});
const params = new URLSearchParams(location.search);
const requestedDays = params.get('days');
if ([...dom.days.options].some((o) => o.value === requestedDays)) dom.days.value = requestedDays;
try {
const { tenants } = await api.listTenants();
dom.tenant.append(...tenants.map((t) => el('option', { value: t.name }, `${t.name} (${t.jobs} job${t.jobs === 1 ? '' : 's'})`)));
} catch (err) {
dom.errorBanner.textContent = `Couldn't load the companies: ${err.message}`;
dom.errorBanner.hidden = false;
return;
}
// ?tenant= from a link: match case-insensitively, as the API does
const requested = (params.get('tenant') ?? '').toLowerCase();
const match = [...dom.tenant.options].find((o) => o.value && o.value.toLowerCase() === requested);
if (match) dom.tenant.value = match.value;
await load();
}
main();
+18
View File
@@ -0,0 +1,18 @@
import { CONFIG } from './config.js';
/**
* Fills in the top bar's "signed in as … / Sign out" from /api/v1/me, and shows the Admin link
* to admins. Pass the elements that exist on the page; missing ones are skipped.
*/
export function showSignedInUser(me, { signedIn, signedInEmail, signOut, adminLink } = {}) {
const who = me?.email || me?.name;
if (who && signedIn) { // nothing to show without Microsoft 365 sign-in (dev, mock)
signedInEmail.textContent = who;
signOut.href = CONFIG.signOutUrl;
signedIn.hidden = false;
}
if (me?.is_admin && adminLink) {
adminLink.href = `/admin.html${location.search}`; // keeps ?mock=1
adminLink.hidden = false;
}
}
+32
View File
@@ -0,0 +1,32 @@
// Run/job statuses: labels, icons, sort order, and the badge that shows one.
import { el } from './dom.js';
// rank drives default sort: worst first
export 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
export function normalizeStatus(raw) {
const s = String(raw ?? '').toLowerCase();
return Object.hasOwn(STATUS, s) ? s : 'unknown';
}
export 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);
}
+117
View File
@@ -0,0 +1,117 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Backup report · Backup Monitor</title>
<link rel="stylesheet" href="/css/styles.css">
<script type="module" src="/js/report.js"></script>
</head>
<body>
<header class="topbar no-print">
<div class="topbar__nav">
<a id="back-link" class="btn" href="/">← Dashboard</a>
<h1 class="topbar__title">Reports</h1>
</div>
<div class="topbar__meta">
<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 container--narrow">
<form id="report-form" class="report-controls no-print">
<div class="report-controls__field">
<label for="tenant">Company</label>
<select id="tenant" name="tenant" class="input" required>
<option value="">Choose a company…</option>
</select>
</div>
<div class="report-controls__field">
<label for="days">Period</label>
<select id="days" name="days" class="input">
<option value="7">Last 7 days</option>
<option value="30" selected>Last 30 days</option>
<option value="90">Last 90 days</option>
</select>
</div>
<div class="report-controls__actions">
<button id="print-btn" class="btn btn--primary" type="button" disabled>Print / Save as PDF</button>
<a id="csv-link" class="btn" aria-disabled="true">Download CSV</a>
</div>
</form>
<div id="error-banner" class="banner banner--error" role="alert" hidden></div>
<p id="choose-hint" class="empty no-print">Choose a company to see its backup report.</p>
<p id="loading" class="muted no-print" hidden>Loading report…</p>
<article id="report" class="report" aria-labelledby="report-title" hidden>
<header class="report__header">
<h2 id="report-title" class="report__title"></h2>
<p id="report-period" class="report__period"></p>
</header>
<section aria-label="Summary">
<dl id="report-summary" class="report-summary"></dl>
</section>
<section aria-labelledby="jobs-heading">
<h3 id="jobs-heading" class="report__section-title">Jobs</h3>
<div class="table-wrap">
<table class="jobs report-table">
<thead>
<tr>
<th scope="col">Job</th>
<th scope="col">Source</th>
<th scope="col" class="num">Runs</th>
<th scope="col" class="num">Successful</th>
<th scope="col" class="num">Warnings</th>
<th scope="col" class="num">Failed</th>
<th scope="col" class="num">Success rate</th>
<th scope="col">Last result</th>
<th scope="col">Last successful backup</th>
</tr>
</thead>
<tbody id="jobs-body"></tbody>
</table>
</div>
</section>
<section aria-labelledby="daily-heading">
<h3 id="daily-heading" class="report__section-title">Day by day</h3>
<ul class="report-legend" aria-label="Legend">
<li><span class="day day--success" aria-hidden="true"></span>Successful</li>
<li><span class="day day--warning" aria-hidden="true"></span>Warning</li>
<li><span class="day day--failed" aria-hidden="true"></span>Failed</li>
<li><span class="day day--running" aria-hidden="true"></span>In progress</li>
<li><span class="day day--none" aria-hidden="true"></span>No backup</li>
<li><span class="day day--untracked" aria-hidden="true"></span>Not monitored yet</li>
</ul>
<div id="daily" class="report-daily"></div>
</section>
<section aria-labelledby="issues-heading">
<h3 id="issues-heading" class="report__section-title">Warnings and failures</h3>
<p id="issues-none" class="muted" hidden>None in this period.</p>
<div id="issues-wrap" class="table-wrap">
<table class="jobs report-table report-issues">
<thead>
<tr>
<th scope="col">When</th>
<th scope="col">Job</th>
<th scope="col">Result</th>
<th scope="col">Details</th>
</tr>
</thead>
<tbody id="issues-body"></tbody>
</table>
</div>
</section>
<footer id="report-footer" class="report__footer muted"></footer>
</article>
</main>
</body>
</html>