Compare commits
5
Commits
m365-sign-in
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd49d85c77 | ||
|
|
2297187f88 | ||
|
|
abb684ff0d | ||
|
|
d4e821799c | ||
|
|
c5fe69a813 |
+17
@@ -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
|
||||||
@@ -67,10 +67,31 @@ 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}` | 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/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/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"}` |
|
| `GET /api/v1/health` | `{"status": "ok"}` |
|
||||||
|
|
||||||
The OpenAPI schema is at `/api/v1/openapi.json`.
|
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
|
## Authentication
|
||||||
|
|
||||||
- **Dashboard users:** Microsoft 365 sign-in. nginx checks every request with
|
- **Dashboard users:** Microsoft 365 sign-in. nginx checks every request with
|
||||||
@@ -79,9 +100,23 @@ The OpenAPI schema is at `/api/v1/openapi.json`.
|
|||||||
signed-in user can see every tenant. Sessions last 12 hours; signing in again is usually silent.
|
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,
|
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.
|
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.
|
- **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
|
`/api/v1/ingest` is the only path that doesn't need Microsoft sign-in.
|
||||||
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
|
### Microsoft 365 sign-in setup
|
||||||
|
|
||||||
@@ -138,6 +173,12 @@ BACKUP_DASH_SERVE_STATIC=1 uvicorn --factory app.main:create_app --reload
|
|||||||
# → http://localhost:8000
|
# → http://localhost:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Collector tests (PowerShell 7 + Pester; see [collectors/tests](collectors/tests/README.md)):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./collectors/tests/Invoke-CollectorTests.ps1
|
||||||
|
```
|
||||||
|
|
||||||
## Deployment (Linux, nginx)
|
## 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`.
|
1. Copy the repo to `/opt/backup-dashboard`, create a venv at `/opt/backup-dashboard/venv`, and run `pip install -r backend/requirements.txt`.
|
||||||
@@ -153,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`
|
Back up the SQLite file with `sqlite3 backup-dashboard.db ".backup copy.db"`. Don't use a plain `cp`
|
||||||
while the service is running.
|
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.
|
||||||
|
|||||||
@@ -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
@@ -1,14 +1,16 @@
|
|||||||
"""Collector (machine) authentication.
|
"""Authentication and authorisation.
|
||||||
|
|
||||||
Dashboard *users* are authenticated by nginx in front of the app (Microsoft 365 sign-in via
|
Collectors (machines) authenticate with API keys, checked here. Dashboard *users* sign in with
|
||||||
oauth2-proxy), so nothing here deals with people.
|
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 hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException
|
from fastapi import Depends, Header, HTTPException, Request
|
||||||
|
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
from .timeutil import to_db, utcnow
|
from .timeutil import to_db, utcnow
|
||||||
@@ -25,6 +27,35 @@ def hash_key(key: str) -> str:
|
|||||||
return hashlib.sha256(key.encode()).hexdigest()
|
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(
|
def require_ingest_key(
|
||||||
authorization: str | None = Header(None),
|
authorization: str | None = Header(None),
|
||||||
conn: sqlite3.Connection = Depends(get_db),
|
conn: sqlite3.Connection = Depends(get_db),
|
||||||
|
|||||||
+17
-2
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 2
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS tenants (
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
@@ -67,6 +67,17 @@ def connect(path: Path | str) -> sqlite3.Connection:
|
|||||||
return conn
|
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:
|
def init_db(path: Path | str) -> None:
|
||||||
conn = connect(path)
|
conn = connect(path)
|
||||||
try:
|
try:
|
||||||
@@ -74,7 +85,11 @@ def init_db(path: Path | str) -> None:
|
|||||||
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version > SCHEMA_VERSION:
|
if version > SCHEMA_VERSION:
|
||||||
raise RuntimeError(f"Database schema v{version} is newer than this app (v{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}")
|
conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -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)
|
||||||
+7
-2
@@ -7,8 +7,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request
|
||||||
|
|
||||||
from . import db, repo
|
from . import admin, db, reports, repo
|
||||||
from .auth import require_ingest_key
|
from .auth import is_admin, require_ingest_key
|
||||||
from .models import IngestIn
|
from .models import IngestIn
|
||||||
from .settings import HISTORY_DAYS, Settings, load_settings
|
from .settings import HISTORY_DAYS, Settings, load_settings
|
||||||
from .timeutil import to_db, utcnow
|
from .timeutil import to_db, utcnow
|
||||||
@@ -32,6 +32,7 @@ def me(request: Request):
|
|||||||
return {
|
return {
|
||||||
"email": request.headers.get("x-forwarded-email") or None,
|
"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,
|
"name": request.headers.get("x-forwarded-preferred-username") or request.headers.get("x-forwarded-user") or None,
|
||||||
|
"is_admin": is_admin(request),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -75,6 +76,8 @@ def ingest(
|
|||||||
conn: sqlite3.Connection = Depends(db.get_db),
|
conn: sqlite3.Connection = Depends(db.get_db),
|
||||||
):
|
):
|
||||||
job_id, run_id = repo.ingest(conn, item)
|
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)
|
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}
|
return {"job_id": job_id, "run_id": run_id}
|
||||||
|
|
||||||
@@ -118,6 +121,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
)
|
)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
app.include_router(admin.router)
|
||||||
|
app.include_router(reports.router)
|
||||||
|
|
||||||
if settings.serve_static:
|
if settings.serve_static:
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|||||||
@@ -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]
|
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:
|
def get_job(conn: sqlite3.Connection, job_id: int) -> dict[str, Any] | None:
|
||||||
row = conn.execute(_JOB_SQL + " WHERE j.id = ?", (job_id,)).fetchone()
|
row = conn.execute(_JOB_SQL + " WHERE j.id = ?", (job_id,)).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
|
|||||||
@@ -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)}"},
|
||||||
|
)
|
||||||
@@ -15,6 +15,8 @@ class Settings:
|
|||||||
retention_days: int = 90
|
retention_days: int = 90
|
||||||
# Dev only: serve ../public from the API so you can run without nginx.
|
# Dev only: serve ../public from the API so you can run without nginx.
|
||||||
serve_static: bool = False
|
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]:
|
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")),
|
db_path=Path(env.get("BACKUP_DASH_DB", "backup-dashboard.db")),
|
||||||
retention_days=max(HISTORY_DAYS, int(env.get("BACKUP_DASH_RETENTION_DAYS", "90"))),
|
retention_days=max(HISTORY_DAYS, int(env.get("BACKUP_DASH_RETENTION_DAYS", "90"))),
|
||||||
serve_static=env.get("BACKUP_DASH_SERVE_STATIC") == "1",
|
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
@@ -15,41 +15,48 @@ Run it as the service user so files it creates stay writable by the API:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import getpass
|
||||||
import random
|
import random
|
||||||
import sys
|
import sys
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app import db, repo
|
from app import db, keys, repo
|
||||||
from app.auth import generate_key, hash_key
|
|
||||||
from app.models import IngestIn
|
from app.models import IngestIn
|
||||||
from app.settings import DEFAULT_ENV_FILE, load_settings
|
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):
|
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:
|
try:
|
||||||
conn.execute(
|
_, secret = keys.create_key(conn, args.name, created_by=cli_user())
|
||||||
"INSERT INTO api_keys (name, key_hash, created_at) VALUES (?, ?, ?)",
|
except keys.KeyNameTaken:
|
||||||
(args.name, hash_key(key), to_db(utcnow())),
|
|
||||||
)
|
|
||||||
except db.sqlite3.IntegrityError:
|
|
||||||
sys.exit(f"A key named {args.name!r} already exists")
|
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):
|
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"):
|
for k in keys.list_keys(conn):
|
||||||
state = f"revoked {r['revoked_at']}" if r["revoked_at"] else "active"
|
state = f"revoked {k['revoked_at']}" if k["revoked_at"] else "active"
|
||||||
print(f"{r['name']:<30} {state:<30} created {r['created_at']} last used {r['last_used_at'] or 'never'}")
|
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):
|
def revoke_key(conn, args):
|
||||||
cur = conn.execute(
|
row = conn.execute("SELECT id FROM api_keys WHERE name = ? AND revoked_at IS NULL", (args.name,)).fetchone()
|
||||||
"UPDATE api_keys SET revoked_at = ? WHERE name = ? AND revoked_at IS NULL", (to_db(utcnow()), args.name)
|
if row is None:
|
||||||
)
|
sys.exit(f"No active key named {args.name!r}")
|
||||||
sys.exit(0 if cur.rowcount else f"No active key named {args.name!r}")
|
keys.revoke_key(conn, row["id"], revoked_by=cli_user())
|
||||||
|
|
||||||
|
|
||||||
def list_jobs(conn, args):
|
def list_jobs(conn, args):
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
fastapi>=0.115
|
fastapi>=0.115
|
||||||
uvicorn[standard]>=0.30
|
uvicorn[standard]>=0.30
|
||||||
|
# Time zone data for report times, where the OS has none
|
||||||
|
tzdata
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -125,6 +125,6 @@ def test_tenant_name_is_case_insensitive(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_me_reports_signed_in_user_from_proxy_headers(client):
|
def test_me_reports_signed_in_user_from_proxy_headers(client):
|
||||||
assert client.get("/api/v1/me").json() == {"email": None, "name": None}
|
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"})
|
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"}
|
assert r.json() == {"email": "[email protected]", "name": "abc-123", "is_admin": False}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,7 +14,7 @@ named `<SERVER> - Azure Backup` by default.
|
|||||||
Runs under Windows PowerShell 5.1 (`powershell.exe`), which the agent's `MSOnlineBackup` module
|
Runs under Windows PowerShell 5.1 (`powershell.exe`), which the agent's `MSOnlineBackup` module
|
||||||
targets and which every supported Windows Server has.
|
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
|
```bash
|
||||||
python manage.py create-key mars-acme-fs01
|
python manage.py create-key mars-acme-fs01
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
# ./Send-MarsBackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
# ./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
|
# 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)
|
$notSet = [datetime]::new(1601, 1, 1)
|
||||||
function New-StableGuid([string]$Seed) {
|
function New-StableGuid([string]$Seed) {
|
||||||
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
||||||
|
|||||||
@@ -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 })
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ one with `python manage.py delete-job <id>`.
|
|||||||
|
|
||||||
## Install (on the VB365 server, as an administrator)
|
## Install (on the VB365 server, as an administrator)
|
||||||
|
|
||||||
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
|
```bash
|
||||||
python manage.py create-key vb365-01
|
python manage.py create-key vb365-01
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
# ./Send-Vb365BackupStatus.ps1 -ConfigPath ./my-test-config.json -DryRun
|
# ./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
|
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same data
|
||||||
$now = Get-Date -Minute 0 -Second 0 -Millisecond 0
|
# 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) {
|
function New-StableGuid([string]$Seed) {
|
||||||
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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
|
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.
|
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
|
```bash
|
||||||
python manage.py create-key veeam-acme
|
python manage.py create-key veeam-acme
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
# Fixed to the top of the hour, and IDs derived from names/times, so repeated runs see the same
|
# 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
|
# 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) {
|
function New-StableGuid([string]$Seed) {
|
||||||
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
[guid]::new([Security.Cryptography.MD5]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Requires the Windows Server Backup feature with its PowerShell module
|
|||||||
(`Install-WindowsFeature Windows-Server-Backup`). Runs under Windows PowerShell 5.1
|
(`Install-WindowsFeature Windows-Server-Backup`). Runs under Windows PowerShell 5.1
|
||||||
(`powershell.exe`), which that module targets.
|
(`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
|
```bash
|
||||||
python manage.py create-key wsb-acme-dc01
|
python manage.py create-key wsb-acme-dc01
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -6,11 +6,13 @@
|
|||||||
#
|
#
|
||||||
# . ./tests/MockWsb.ps1 # nothing running
|
# . ./tests/MockWsb.ps1 # nothing running
|
||||||
# . ./tests/MockWsb.ps1 -WithRunningJob # a backup in progress
|
# . ./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
|
# ./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
|
# 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
|
# Same format as the real cmdlet: short date + short time in the current culture
|
||||||
function Format-WbTime($Value) {
|
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 '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
|
(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]@{
|
$global:MockWsbSummary = [pscustomobject]@{
|
||||||
NextBackupTime = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
|
NextBackupTime = $now.Date.AddHours(21).AddDays($(if ($now.Hour -ge 21) { 1 } else { 0 }))
|
||||||
|
|||||||
@@ -2,3 +2,5 @@
|
|||||||
# Read by both the API service and manage.py, so they always use the same database.
|
# 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_DB=/var/lib/backup-dashboard/backup-dashboard.db
|
||||||
BACKUP_DASH_RETENTION_DAYS=90
|
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]
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -345,3 +345,181 @@ button.badge:hover { filter: brightness(0.95); }
|
|||||||
.runs tbody tr:last-child td { border-bottom: none; }
|
.runs tbody tr:last-child td { border-bottom: none; }
|
||||||
.runs tr.is-selected td { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
.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; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
<div class="topbar__meta">
|
<div class="topbar__meta">
|
||||||
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
|
<span id="last-updated" class="muted" aria-live="polite">Loading…</span>
|
||||||
<button id="refresh-btn" class="btn" type="button">Refresh</button>
|
<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 -->
|
<!-- Filled in from /api/v1/me when signed in with Microsoft 365 -->
|
||||||
<span id="signed-in" class="topbar__user" hidden>
|
<span id="signed-in" class="topbar__user" hidden>
|
||||||
<span id="signed-in-email" class="muted"></span>
|
<span id="signed-in-email" class="muted"></span>
|
||||||
|
|||||||
@@ -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();
|
||||||
+57
-5
@@ -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 controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
|
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 {
|
try {
|
||||||
const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, {
|
const res = await fetch(`${CONFIG.apiBaseUrl}${path}`, {
|
||||||
headers: { Accept: 'application/json' },
|
method,
|
||||||
credentials: 'same-origin', // ready for cookie/session auth behind nginx
|
headers,
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
credentials: 'same-origin', // the sign-in session cookie
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
// 401 means the nginx/SSO session lapsed; a reload sends the user back through sign-in
|
// 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.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();
|
return await res.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') throw new ApiError('Request timed out');
|
if (err.name === 'AbortError') throw new ApiError('Request timed out');
|
||||||
@@ -49,10 +72,39 @@ const httpApi = {
|
|||||||
getRun(id) {
|
getRun(id) {
|
||||||
return request(`/runs/${encodeURIComponent(id)}`);
|
return request(`/runs/${encodeURIComponent(id)}`);
|
||||||
},
|
},
|
||||||
/** GET /me → { email, name } of the Microsoft 365 user, both null without sign-in */
|
/** GET /me → { email, name, is_admin } of the Microsoft 365 user; email/name null without sign-in */
|
||||||
getMe() {
|
getMe() {
|
||||||
return request('/me');
|
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.
|
// Mock is loaded lazily so it never ships in the production request path.
|
||||||
|
|||||||
+20
-100
@@ -1,5 +1,11 @@
|
|||||||
import { CONFIG } from './config.js';
|
import { CONFIG } from './config.js';
|
||||||
import { getApi } from './api.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 {
|
import {
|
||||||
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
|
parseDate, formatDateTime, formatTime, formatRelative, formatDuration, formatBytes,
|
||||||
} from './format.js';
|
} from './format.js';
|
||||||
@@ -7,20 +13,6 @@ import {
|
|||||||
const api = await getApi();
|
const api = await getApi();
|
||||||
|
|
||||||
// ---------- Domain ----------
|
// ---------- 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) {
|
function normalizeJob(j) {
|
||||||
return {
|
return {
|
||||||
@@ -59,6 +51,8 @@ const dom = {
|
|||||||
signedIn: $('#signed-in'),
|
signedIn: $('#signed-in'),
|
||||||
signedInEmail: $('#signed-in-email'),
|
signedInEmail: $('#signed-in-email'),
|
||||||
signOut: $('#sign-out'),
|
signOut: $('#sign-out'),
|
||||||
|
adminLink: $('#admin-link'),
|
||||||
|
reportsLink: $('#reports-link'),
|
||||||
lastUpdated: $('#last-updated'),
|
lastUpdated: $('#last-updated'),
|
||||||
errorBanner: $('#error-banner'),
|
errorBanner: $('#error-banner'),
|
||||||
dialog: $('#detail-dialog'),
|
dialog: $('#detail-dialog'),
|
||||||
@@ -67,34 +61,7 @@ const dom = {
|
|||||||
detailBody: $('#detail-body'),
|
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 ----------
|
// ---------- 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);
|
const runningTitle = (since) => (since ? `Running since ${formatDateTime(since)}` : null);
|
||||||
|
|
||||||
@@ -241,51 +208,6 @@ const modal = {
|
|||||||
runMessageError: null,
|
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) {
|
function detailRow(label, value) {
|
||||||
if (value == null || value === '') return [];
|
if (value == null || value === '') return [];
|
||||||
return [el('dt', {}, label), el('dd', {}, value)];
|
return [el('dt', {}, label), el('dd', {}, value)];
|
||||||
@@ -298,7 +220,8 @@ function renderSummarySection() {
|
|||||||
const lastStatus = detail?.last_status ? normalizeStatus(detail.last_status) : null;
|
const lastStatus = detail?.last_status ? normalizeStatus(detail.last_status) : null;
|
||||||
|
|
||||||
dom.detailTitle.textContent = detail?.job_name ?? job.name;
|
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', {},
|
return el('section', {},
|
||||||
el('div', { class: 'detail-status' },
|
el('div', { class: 'detail-status' },
|
||||||
@@ -527,20 +450,17 @@ dom.search.addEventListener('input', () => {
|
|||||||
|
|
||||||
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
|
dom.refreshBtn.addEventListener('click', () => refresh().then(schedulePoll));
|
||||||
|
|
||||||
// ---------- Signed-in user ----------
|
// A company's report page (keeps ?mock=1)
|
||||||
async function showSignedInUser() {
|
function reportUrl(company) {
|
||||||
try {
|
const params = new URLSearchParams({ tenant: company, days: '30' });
|
||||||
const me = await api.getMe();
|
if (CONFIG.useMock) params.set('mock', '1');
|
||||||
const who = me?.email || me?.name;
|
return `/report.html?${params}`;
|
||||||
if (!who) return; // no Microsoft 365 sign-in in front of the dashboard (dev, mock)
|
|
||||||
dom.signedInEmail.textContent = who;
|
|
||||||
dom.signOut.href = CONFIG.signOutUrl;
|
|
||||||
dom.signedIn.hidden = false;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err); // not worth a banner: the jobs request will surface real problems
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Boot ----------
|
// ---------- Boot ----------
|
||||||
showSignedInUser();
|
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);
|
refresh().then(schedulePoll);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(', ');
|
||||||
|
}
|
||||||
+78
-1
@@ -96,6 +96,18 @@ for (const job of JOBS) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
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;
|
const summary = ({ details, every_h, last_status, duration_seconds, size_bytes, ...rest }) => rest;
|
||||||
|
|
||||||
export const mockApi = {
|
export const mockApi = {
|
||||||
@@ -127,6 +139,71 @@ export const mockApi = {
|
|||||||
throw new Error('Run not found');
|
throw new Error('Run not found');
|
||||||
},
|
},
|
||||||
async getMe() {
|
async getMe() {
|
||||||
return { email: null, name: null }; // no sign-in in mock mode
|
// 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 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user