ui: migrate soteria console to react and add b2 telemetry

This commit is contained in:
Brad Stein 2026-04-12 19:45:23 -03:00
parent a5aa9e6a5f
commit 42fa848a82
26 changed files with 3753 additions and 616 deletions

2
.gitignore vendored
View File

@ -4,3 +4,5 @@
*.log
.env
.DS_Store
/web/node_modules/
/web/dist/

View File

@ -1,4 +1,12 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS ui-builder
WORKDIR /src/web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM --platform=$BUILDPLATFORM golang:1.25.0 AS builder
WORKDIR /src
@ -6,6 +14,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=ui-builder /src/web/dist ./internal/server/ui-dist
ARG TARGETOS
ARG TARGETARCH

View File

@ -7,8 +7,8 @@ Soteria is an in-cluster service for PVC backup and restore operations. The curr
- Namespace-wide backup and restore batch execution.
- Restore into a new target PVC with conflict checks and best-effort cleanup on failure.
- Policy-based scheduled backups (per PVC or all PVCs in a namespace), persisted in-cluster.
- A simple built-in UI suitable for publishing behind an authenticated ingress.
- Prometheus-format backup freshness telemetry for Grafana rollups.
- A built-in React + TypeScript UI (dark-mode default) suitable for publishing behind an authenticated ingress.
- Prometheus-format backup freshness and B2 consumption telemetry for Grafana rollups.
For Longhorn, backups are crash-consistent at the volume level and delegated to the Longhorn control plane.
@ -34,6 +34,7 @@ Protected endpoints when `SOTERIA_AUTH_REQUIRED=true`:
- `GET /v1/policies`
- `POST /v1/policies`
- `DELETE /v1/policies/<policy-id>`
- `GET /v1/b2`
## API examples
@ -163,6 +164,37 @@ POST /v1/policies
- Leave `pvc` empty to target all PVCs in that namespace.
- Policies are stored in secret `SOTERIA_POLICY_SECRET_NAME` under key `policies.json`.
### GET /v1/b2
Returns B2 account/bucket consumption based on S3-compatible object scans.
```json
{
"enabled": true,
"available": true,
"endpoint": "https://s3.us-west-004.backblazeb2.com",
"region": "us-west-004",
"scanned_at": "2026-04-12T16:00:00Z",
"scan_duration_ms": 824,
"total_objects": 1324,
"total_bytes": 18407542931,
"recent_objects_24h": 18,
"recent_bytes_24h": 12245812,
"buckets": [
{
"name": "atlas-backups",
"object_count": 1240,
"total_bytes": 18288473811,
"recent_objects_24h": 12,
"recent_bytes_24h": 8542198,
"last_modified_at": "2026-04-12T15:43:19Z"
}
]
}
```
Recent 24h values are an object-change proxy and do not represent full B2 billing egress totals.
## Authentication and authorization
When `SOTERIA_AUTH_REQUIRED=true`, Soteria expects trusted auth headers from a fronting proxy such as `oauth2-proxy`:
@ -194,8 +226,24 @@ Implemented metrics:
- `soteria_inventory_refresh_timestamp_seconds`
- `pvc_backup_age_hours{namespace,pvc,volume,driver}`
- `pvc_backup_health{namespace,pvc,volume,driver}`
- `pvc_backup_health_reason{namespace,pvc,volume,driver,reason}`
- `pvc_backup_last_success_timestamp_seconds{namespace,pvc,volume,driver}`
- `pvc_backup_count{namespace,pvc,volume,driver}`
- `pvc_backup_completed_count{namespace,pvc,volume,driver}`
- `pvc_backup_last_size_bytes{namespace,pvc,volume,driver}`
- `pvc_backup_total_size_bytes{namespace,pvc,volume,driver}`
- `soteria_b2_scan_success`
- `soteria_b2_scan_timestamp_seconds`
- `soteria_b2_scan_duration_seconds`
- `soteria_b2_account_objects`
- `soteria_b2_account_bytes`
- `soteria_b2_account_recent_objects_24h`
- `soteria_b2_account_recent_bytes_24h`
- `soteria_b2_bucket_objects{bucket}`
- `soteria_b2_bucket_bytes{bucket}`
- `soteria_b2_bucket_recent_objects_24h{bucket}`
- `soteria_b2_bucket_recent_bytes_24h{bucket}`
- `soteria_b2_bucket_last_modified_timestamp_seconds{bucket}`
`pvc_backup_health` is `1` when the most recent successful backup is within `SOTERIA_BACKUP_MAX_AGE_HOURS`, otherwise `0`.
@ -225,6 +273,19 @@ Environment variables:
- `SOTERIA_METRICS_REFRESH_SECONDS` default `300`
- `SOTERIA_POLICY_EVAL_SECONDS` default `300`
- `SOTERIA_POLICY_SECRET_NAME` default `soteria-policies`
- `SOTERIA_B2_ENABLED` default `false` (auto-enabled if endpoint/secret are set)
- `SOTERIA_B2_ENDPOINT` optional S3-compatible endpoint (for B2, usually `https://s3.<region>.backblazeb2.com`)
- `SOTERIA_B2_REGION` optional region override (auto-inferred for Backblaze endpoint patterns)
- `SOTERIA_B2_BUCKETS` optional comma-separated bucket allowlist (defaults to scanning all accessible buckets)
- `SOTERIA_B2_ACCESS_KEY_ID` optional static key (can come from secret instead)
- `SOTERIA_B2_SECRET_ACCESS_KEY` optional static secret key (can come from secret instead)
- `SOTERIA_B2_SECRET_NAMESPACE` optional secret namespace (defaults to service namespace when secret name is set)
- `SOTERIA_B2_SECRET_NAME` optional secret containing B2 keys
- `SOTERIA_B2_ACCESS_KEY_FIELD` default `AWS_ACCESS_KEY_ID`
- `SOTERIA_B2_SECRET_KEY_FIELD` default `AWS_SECRET_ACCESS_KEY`
- `SOTERIA_B2_ENDPOINT_FIELD` default `AWS_ENDPOINTS`
- `SOTERIA_B2_SCAN_INTERVAL_SECONDS` default `900`
- `SOTERIA_B2_SCAN_TIMEOUT_SECONDS` default `120`
## Secrets
@ -236,6 +297,12 @@ Create a secret named `soteria-restic` in the Soteria namespace, or set `SOTERIA
The service copies this secret into the target namespace per job and attaches an owner reference so it is cleaned up with the Job.
For B2 scanning, you can point Soteria at a secret via `SOTERIA_B2_SECRET_NAME`. Expected keys by default:
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
- `AWS_ENDPOINTS` (optional if `SOTERIA_B2_ENDPOINT` is set)
A template is in `deploy/secret-example.yaml`. Do not commit real credentials.
## Deployment
@ -253,6 +320,7 @@ The example Service is annotated for Prometheus scraping of `/metrics`.
## Notes
- Longhorn inventory and metrics are based on discovered backup records per PVC.
- Inventory `Restore` buttons load source context into the restore planner; restore execution happens from the planner panel.
- Scheduled policy execution currently applies to Longhorn driver.
- Restic backup and restore execution exists, but inventory-style telemetry is currently Longhorn-focused.
- For Atlas production, place Soteria behind an authenticated ingress and trust only proxy-injected auth headers.

View File

@ -13,3 +13,6 @@ data:
SOTERIA_ALLOWED_GROUPS: "admin,maintenance"
SOTERIA_BACKUP_MAX_AGE_HOURS: "24"
SOTERIA_METRICS_REFRESH_SECONDS: "300"
SOTERIA_B2_ENABLED: "false"
SOTERIA_B2_SCAN_INTERVAL_SECONDS: "900"
SOTERIA_B2_SCAN_TIMEOUT_SECONDS: "120"

20
go.mod
View File

@ -3,6 +3,7 @@ module scm.bstein.dev/bstein/soteria
go 1.25.0
require (
github.com/minio/minio-go/v7 v7.0.100
k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
@ -10,8 +11,10 @@ require (
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
@ -20,19 +23,28 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect

53
go.sum
View File

@ -4,10 +4,14 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
@ -33,6 +37,13 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@ -42,6 +53,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -54,10 +71,14 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -71,30 +92,34 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -53,20 +53,23 @@ type NamespaceInventory struct {
}
type PVCInventory struct {
Namespace string `json:"namespace"`
PVC string `json:"pvc"`
Volume string `json:"volume,omitempty"`
Phase string `json:"phase,omitempty"`
StorageClass string `json:"storage_class,omitempty"`
Capacity string `json:"capacity,omitempty"`
AccessModes []string `json:"access_modes,omitempty"`
Driver string `json:"driver,omitempty"`
LastBackupAt string `json:"last_backup_at,omitempty"`
LastBackupAgeHours float64 `json:"last_backup_age_hours,omitempty"`
BackupCount int `json:"backup_count"`
Healthy bool `json:"healthy"`
HealthReason string `json:"health_reason,omitempty"`
Error string `json:"error,omitempty"`
Namespace string `json:"namespace"`
PVC string `json:"pvc"`
Volume string `json:"volume,omitempty"`
Phase string `json:"phase,omitempty"`
StorageClass string `json:"storage_class,omitempty"`
Capacity string `json:"capacity,omitempty"`
AccessModes []string `json:"access_modes,omitempty"`
Driver string `json:"driver,omitempty"`
LastBackupAt string `json:"last_backup_at,omitempty"`
LastBackupAgeHours float64 `json:"last_backup_age_hours,omitempty"`
BackupCount int `json:"backup_count"`
CompletedBackups int `json:"completed_backups"`
LastBackupSizeBytes float64 `json:"last_backup_size_bytes,omitempty"`
TotalBackupSizeBytes float64 `json:"total_backup_size_bytes,omitempty"`
Healthy bool `json:"healthy"`
HealthReason string `json:"health_reason,omitempty"`
Error string `json:"error,omitempty"`
}
type BackupListResponse struct {
@ -169,3 +172,27 @@ type NamespaceRestoreResponse struct {
Failed int `json:"failed"`
Results []NamespaceRestoreResult `json:"results"`
}
type B2UsageResponse struct {
Enabled bool `json:"enabled"`
Available bool `json:"available"`
Endpoint string `json:"endpoint,omitempty"`
Region string `json:"region,omitempty"`
ScannedAt string `json:"scanned_at,omitempty"`
ScanDurationMS int64 `json:"scan_duration_ms,omitempty"`
TotalObjects int64 `json:"total_objects"`
TotalBytes int64 `json:"total_bytes"`
RecentObjects24h int64 `json:"recent_objects_24h"`
RecentBytes24h int64 `json:"recent_bytes_24h"`
Buckets []B2BucketUsage `json:"buckets,omitempty"`
Error string `json:"error,omitempty"`
}
type B2BucketUsage struct {
Name string `json:"name"`
ObjectCount int64 `json:"object_count"`
TotalBytes int64 `json:"total_bytes"`
RecentObjects24h int64 `json:"recent_objects_24h"`
RecentBytes24h int64 `json:"recent_bytes_24h"`
LastModifiedAt string `json:"last_modified_at,omitempty"`
}

View File

@ -21,6 +21,8 @@ const (
defaultPolicyEval = 300 * time.Second
defaultBackupMaxAge = 24 * time.Hour
defaultPolicySecret = "soteria-policies"
defaultB2ScanInterval = 15 * time.Minute
defaultB2ScanTimeout = 2 * time.Minute
serviceNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
)
@ -48,6 +50,19 @@ type Config struct {
PolicyEvalInterval time.Duration
PolicySecretName string
BackupMaxAge time.Duration
B2Enabled bool
B2Endpoint string
B2Region string
B2Buckets []string
B2AccessKeyID string
B2SecretAccessKey string
B2SecretNamespace string
B2SecretName string
B2AccessKeyField string
B2SecretKeyField string
B2EndpointField string
B2ScanInterval time.Duration
B2ScanTimeout time.Duration
}
func Load() (*Config, error) {
@ -87,6 +102,19 @@ func Load() (*Config, error) {
cfg.PolicyEvalInterval = defaultPolicyEval
cfg.BackupMaxAge = defaultBackupMaxAge
cfg.PolicySecretName = getenvDefault("SOTERIA_POLICY_SECRET_NAME", defaultPolicySecret)
cfg.B2Enabled = getenvBool("SOTERIA_B2_ENABLED")
cfg.B2Endpoint = getenv("SOTERIA_B2_ENDPOINT")
cfg.B2Region = getenv("SOTERIA_B2_REGION")
cfg.B2Buckets = parseCSV(getenv("SOTERIA_B2_BUCKETS"))
cfg.B2AccessKeyID = getenv("SOTERIA_B2_ACCESS_KEY_ID")
cfg.B2SecretAccessKey = getenv("SOTERIA_B2_SECRET_ACCESS_KEY")
cfg.B2SecretNamespace = getenv("SOTERIA_B2_SECRET_NAMESPACE")
cfg.B2SecretName = getenv("SOTERIA_B2_SECRET_NAME")
cfg.B2AccessKeyField = getenvDefault("SOTERIA_B2_ACCESS_KEY_FIELD", "AWS_ACCESS_KEY_ID")
cfg.B2SecretKeyField = getenvDefault("SOTERIA_B2_SECRET_KEY_FIELD", "AWS_SECRET_ACCESS_KEY")
cfg.B2EndpointField = getenvDefault("SOTERIA_B2_ENDPOINT_FIELD", "AWS_ENDPOINTS")
cfg.B2ScanInterval = defaultB2ScanInterval
cfg.B2ScanTimeout = defaultB2ScanTimeout
if ttl, ok := getenvInt("SOTERIA_JOB_TTL_SECONDS"); ok {
cfg.JobTTLSeconds = int32(ttl)
@ -103,6 +131,18 @@ func Load() (*Config, error) {
if hours, ok := getenvFloat("SOTERIA_BACKUP_MAX_AGE_HOURS"); ok {
cfg.BackupMaxAge = time.Duration(hours * float64(time.Hour))
}
if seconds, ok := getenvInt("SOTERIA_B2_SCAN_INTERVAL_SECONDS"); ok {
cfg.B2ScanInterval = time.Duration(seconds) * time.Second
}
if seconds, ok := getenvInt("SOTERIA_B2_SCAN_TIMEOUT_SECONDS"); ok {
cfg.B2ScanTimeout = time.Duration(seconds) * time.Second
}
if !cfg.B2Enabled && (cfg.B2Endpoint != "" || cfg.B2SecretName != "") {
cfg.B2Enabled = true
}
if cfg.B2SecretName != "" && cfg.B2SecretNamespace == "" {
cfg.B2SecretNamespace = cfg.Namespace
}
if cfg.ResticRepository == "" {
if cfg.BackupDriver == "restic" {
@ -148,6 +188,23 @@ func Load() (*Config, error) {
if cfg.BackupMaxAge <= 0 {
return nil, errors.New("SOTERIA_BACKUP_MAX_AGE_HOURS must be greater than zero")
}
if cfg.B2Enabled {
if cfg.B2Endpoint == "" && cfg.B2SecretName == "" {
return nil, errors.New("SOTERIA_B2_ENDPOINT or SOTERIA_B2_SECRET_NAME is required when B2 monitoring is enabled")
}
if strings.TrimSpace(cfg.B2AccessKeyField) == "" {
return nil, errors.New("SOTERIA_B2_ACCESS_KEY_FIELD must not be empty")
}
if strings.TrimSpace(cfg.B2SecretKeyField) == "" {
return nil, errors.New("SOTERIA_B2_SECRET_KEY_FIELD must not be empty")
}
if cfg.B2ScanInterval <= 0 {
return nil, errors.New("SOTERIA_B2_SCAN_INTERVAL_SECONDS must be greater than zero")
}
if cfg.B2ScanTimeout <= 0 {
return nil, errors.New("SOTERIA_B2_SCAN_TIMEOUT_SECONDS must be greater than zero")
}
}
return cfg, nil
}

278
internal/server/b2.go Normal file
View File

@ -0,0 +1,278 @@
package server
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"scm.bstein.dev/bstein/soteria/internal/api"
"github.com/minio/minio-go/v7"
miniocreds "github.com/minio/minio-go/v7/pkg/credentials"
)
type b2Credentials struct {
Endpoint string
Region string
AccessKeyID string
SecretAccessKey string
}
func (s *Server) handleB2Usage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
snapshot := s.getB2Usage()
if s.cfg.B2Enabled && snapshot.ScannedAt == "" {
s.refreshB2Usage(r.Context())
snapshot = s.getB2Usage()
}
writeJSON(w, http.StatusOK, snapshot)
}
func (s *Server) getB2Usage() api.B2UsageResponse {
s.b2Mu.RLock()
defer s.b2Mu.RUnlock()
return s.b2Usage
}
func (s *Server) setB2Usage(usage api.B2UsageResponse) {
s.b2Mu.Lock()
defer s.b2Mu.Unlock()
s.b2Usage = usage
}
func (s *Server) refreshB2Usage(ctx context.Context) {
usage := api.B2UsageResponse{
Enabled: s.cfg.B2Enabled,
Endpoint: s.cfg.B2Endpoint,
Region: s.cfg.B2Region,
}
if !s.cfg.B2Enabled {
s.setB2Usage(usage)
s.metrics.RecordB2Usage(usage)
return
}
startedAt := time.Now()
scanCtx, cancel := context.WithTimeout(ctx, s.cfg.B2ScanTimeout)
defer cancel()
creds, err := s.resolveB2Credentials(scanCtx)
if err != nil {
usage.ScannedAt = time.Now().UTC().Format(time.RFC3339)
usage.ScanDurationMS = time.Since(startedAt).Milliseconds()
usage.Error = err.Error()
s.setB2Usage(usage)
s.metrics.RecordB2Usage(usage)
return
}
usage.Endpoint = creds.Endpoint
usage.Region = creds.Region
scanned, err := scanB2Usage(scanCtx, creds, s.cfg.B2Buckets)
if err != nil {
usage.ScannedAt = time.Now().UTC().Format(time.RFC3339)
usage.ScanDurationMS = time.Since(startedAt).Milliseconds()
usage.Error = err.Error()
s.setB2Usage(usage)
s.metrics.RecordB2Usage(usage)
return
}
scanned.Enabled = true
scanned.Endpoint = creds.Endpoint
scanned.Region = creds.Region
scanned.ScannedAt = time.Now().UTC().Format(time.RFC3339)
scanned.ScanDurationMS = time.Since(startedAt).Milliseconds()
s.setB2Usage(scanned)
s.metrics.RecordB2Usage(scanned)
}
func (s *Server) resolveB2Credentials(ctx context.Context) (b2Credentials, error) {
creds := b2Credentials{
Endpoint: strings.TrimSpace(s.cfg.B2Endpoint),
Region: strings.TrimSpace(s.cfg.B2Region),
AccessKeyID: strings.TrimSpace(s.cfg.B2AccessKeyID),
SecretAccessKey: strings.TrimSpace(s.cfg.B2SecretAccessKey),
}
if s.cfg.B2SecretName != "" {
if creds.AccessKeyID == "" {
value, err := s.loadB2SecretValue(ctx, s.cfg.B2AccessKeyField)
if err != nil {
return b2Credentials{}, err
}
creds.AccessKeyID = value
}
if creds.SecretAccessKey == "" {
value, err := s.loadB2SecretValue(ctx, s.cfg.B2SecretKeyField)
if err != nil {
return b2Credentials{}, err
}
creds.SecretAccessKey = value
}
if creds.Endpoint == "" && strings.TrimSpace(s.cfg.B2EndpointField) != "" {
value, err := s.loadB2SecretValue(ctx, s.cfg.B2EndpointField)
if err != nil {
return b2Credentials{}, err
}
creds.Endpoint = value
}
}
if creds.Endpoint == "" {
return b2Credentials{}, errors.New("B2 endpoint is not configured")
}
if creds.AccessKeyID == "" {
return b2Credentials{}, errors.New("B2 access key ID is not configured")
}
if creds.SecretAccessKey == "" {
return b2Credentials{}, errors.New("B2 secret access key is not configured")
}
creds.Region = inferB2Region(creds.Endpoint, creds.Region)
return creds, nil
}
func (s *Server) loadB2SecretValue(ctx context.Context, key string) (string, error) {
key = strings.TrimSpace(key)
if key == "" {
return "", errors.New("B2 secret key name is empty")
}
data, err := s.client.LoadSecretData(ctx, s.cfg.B2SecretNamespace, s.cfg.B2SecretName, key)
if err != nil {
return "", fmt.Errorf("load B2 secret %s/%s key %s: %w", s.cfg.B2SecretNamespace, s.cfg.B2SecretName, key, err)
}
value := strings.TrimSpace(string(data))
if value == "" {
return "", fmt.Errorf("B2 secret %s/%s key %s is empty", s.cfg.B2SecretNamespace, s.cfg.B2SecretName, key)
}
return value, nil
}
func scanB2Usage(ctx context.Context, creds b2Credentials, configuredBuckets []string) (api.B2UsageResponse, error) {
endpoint, secure, err := normalizeS3Endpoint(creds.Endpoint)
if err != nil {
return api.B2UsageResponse{}, err
}
client, err := minio.New(endpoint, &minio.Options{
Creds: miniocreds.NewStaticV4(creds.AccessKeyID, creds.SecretAccessKey, ""),
Secure: secure,
Region: creds.Region,
BucketLookup: minio.BucketLookupPath,
})
if err != nil {
return api.B2UsageResponse{}, fmt.Errorf("init B2 S3 client: %w", err)
}
bucketNames := make([]string, 0, len(configuredBuckets))
for _, bucket := range configuredBuckets {
bucket = strings.TrimSpace(bucket)
if bucket == "" {
continue
}
bucketNames = append(bucketNames, bucket)
}
if len(bucketNames) == 0 {
buckets, err := client.ListBuckets(ctx)
if err != nil {
return api.B2UsageResponse{}, fmt.Errorf("list B2 buckets: %w", err)
}
for _, bucket := range buckets {
bucketNames = append(bucketNames, bucket.Name)
}
}
if len(bucketNames) == 0 {
return api.B2UsageResponse{}, errors.New("no B2 buckets available for scan")
}
sort.Strings(bucketNames)
cutoff := time.Now().UTC().Add(-24 * time.Hour)
result := api.B2UsageResponse{
Enabled: true,
Available: true,
Buckets: make([]api.B2BucketUsage, 0, len(bucketNames)),
}
for _, bucketName := range bucketNames {
bucketUsage := api.B2BucketUsage{Name: bucketName}
lastModified := time.Time{}
objects := client.ListObjects(ctx, bucketName, minio.ListObjectsOptions{Recursive: true})
for object := range objects {
if object.Err != nil {
return api.B2UsageResponse{}, fmt.Errorf("scan B2 bucket %s: %w", bucketName, object.Err)
}
bucketUsage.ObjectCount++
bucketUsage.TotalBytes += object.Size
modified := object.LastModified.UTC()
if !modified.IsZero() {
if modified.After(cutoff) {
bucketUsage.RecentObjects24h++
bucketUsage.RecentBytes24h += object.Size
}
if modified.After(lastModified) {
lastModified = modified
}
}
}
if !lastModified.IsZero() {
bucketUsage.LastModifiedAt = lastModified.Format(time.RFC3339)
}
result.Buckets = append(result.Buckets, bucketUsage)
result.TotalObjects += bucketUsage.ObjectCount
result.TotalBytes += bucketUsage.TotalBytes
result.RecentObjects24h += bucketUsage.RecentObjects24h
result.RecentBytes24h += bucketUsage.RecentBytes24h
}
return result, nil
}
func normalizeS3Endpoint(raw string) (string, bool, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false, errors.New("S3 endpoint is empty")
}
if strings.Contains(raw, "://") {
parsed, err := url.Parse(raw)
if err != nil {
return "", false, fmt.Errorf("parse S3 endpoint: %w", err)
}
if parsed.Host == "" {
return "", false, errors.New("S3 endpoint host is empty")
}
return parsed.Host, !strings.EqualFold(parsed.Scheme, "http"), nil
}
return strings.TrimRight(raw, "/"), true, nil
}
func inferB2Region(endpoint, fallback string) string {
fallback = strings.TrimSpace(fallback)
host, _, err := normalizeS3Endpoint(endpoint)
if err != nil {
return fallback
}
if strings.Contains(host, ":") {
host = strings.Split(host, ":")[0]
}
if strings.HasPrefix(host, "s3.") && strings.HasSuffix(host, ".backblazeb2.com") {
region := strings.TrimPrefix(host, "s3.")
region = strings.TrimSuffix(region, ".backblazeb2.com")
if region != "" {
return region
}
}
return fallback
}

View File

@ -28,8 +28,24 @@ type telemetry struct {
inventoryRefreshTime float64
pvcBackupAgeHours map[string]metricSample
pvcBackupHealth map[string]metricSample
pvcBackupHealthReason map[string]metricSample
pvcBackupLastSuccess map[string]metricSample
pvcBackupCount map[string]metricSample
pvcBackupCompletedCount map[string]metricSample
pvcBackupLastSizeBytes map[string]metricSample
pvcBackupTotalSizeBytes map[string]metricSample
b2BucketObjects map[string]metricSample
b2BucketBytes map[string]metricSample
b2BucketRecentObjects map[string]metricSample
b2BucketRecentBytes map[string]metricSample
b2BucketLastModified map[string]metricSample
b2ScanSuccess float64
b2ScanTimestamp float64
b2ScanDurationSeconds float64
b2AccountObjects float64
b2AccountBytes float64
b2AccountRecentObjects float64
b2AccountRecentBytes float64
}
func newTelemetry() *telemetry {
@ -42,8 +58,17 @@ func newTelemetry() *telemetry {
authzDenials: map[string]metricSample{},
pvcBackupAgeHours: map[string]metricSample{},
pvcBackupHealth: map[string]metricSample{},
pvcBackupHealthReason: map[string]metricSample{},
pvcBackupLastSuccess: map[string]metricSample{},
pvcBackupCount: map[string]metricSample{},
pvcBackupCompletedCount: map[string]metricSample{},
pvcBackupLastSizeBytes: map[string]metricSample{},
pvcBackupTotalSizeBytes: map[string]metricSample{},
b2BucketObjects: map[string]metricSample{},
b2BucketBytes: map[string]metricSample{},
b2BucketRecentObjects: map[string]metricSample{},
b2BucketRecentBytes: map[string]metricSample{},
b2BucketLastModified: map[string]metricSample{},
}
}
@ -102,8 +127,12 @@ func (t *telemetry) RecordInventory(inv api.InventoryResponse) {
t.pvcBackupAgeHours = map[string]metricSample{}
t.pvcBackupHealth = map[string]metricSample{}
t.pvcBackupHealthReason = map[string]metricSample{}
t.pvcBackupLastSuccess = map[string]metricSample{}
t.pvcBackupCount = map[string]metricSample{}
t.pvcBackupCompletedCount = map[string]metricSample{}
t.pvcBackupLastSizeBytes = map[string]metricSample{}
t.pvcBackupTotalSizeBytes = map[string]metricSample{}
for _, namespace := range inv.Namespaces {
for _, pvc := range namespace.PVCs {
@ -114,6 +143,16 @@ func (t *telemetry) RecordInventory(inv api.InventoryResponse) {
"driver": pvc.Driver,
}
setMetric(t.pvcBackupCount, labels, float64(pvc.BackupCount))
setMetric(t.pvcBackupCompletedCount, labels, float64(pvc.CompletedBackups))
setMetric(t.pvcBackupLastSizeBytes, labels, pvc.LastBackupSizeBytes)
setMetric(t.pvcBackupTotalSizeBytes, labels, pvc.TotalBackupSizeBytes)
reasonLabels := cloneLabels(labels)
reason := strings.TrimSpace(pvc.HealthReason)
if reason == "" {
reason = "unknown"
}
reasonLabels["reason"] = reason
setMetric(t.pvcBackupHealthReason, reasonLabels, 1)
if pvc.Healthy {
setMetric(t.pvcBackupHealth, labels, 1)
} else {
@ -132,6 +171,50 @@ func (t *telemetry) RecordInventory(inv api.InventoryResponse) {
t.inventoryRefreshTime = float64(time.Now().Unix())
}
func (t *telemetry) RecordB2Usage(usage api.B2UsageResponse) {
t.mu.Lock()
defer t.mu.Unlock()
t.b2BucketObjects = map[string]metricSample{}
t.b2BucketBytes = map[string]metricSample{}
t.b2BucketRecentObjects = map[string]metricSample{}
t.b2BucketRecentBytes = map[string]metricSample{}
t.b2BucketLastModified = map[string]metricSample{}
t.b2ScanSuccess = 0
t.b2ScanDurationSeconds = float64(usage.ScanDurationMS) / 1000.0
t.b2AccountObjects = float64(usage.TotalObjects)
t.b2AccountBytes = float64(usage.TotalBytes)
t.b2AccountRecentObjects = float64(usage.RecentObjects24h)
t.b2AccountRecentBytes = float64(usage.RecentBytes24h)
if usage.Available {
t.b2ScanSuccess = 1
}
if usage.ScannedAt != "" {
if ts, err := time.Parse(time.RFC3339, usage.ScannedAt); err == nil {
t.b2ScanTimestamp = float64(ts.Unix())
} else {
t.b2ScanTimestamp = float64(time.Now().Unix())
}
} else {
t.b2ScanTimestamp = float64(time.Now().Unix())
}
for _, bucket := range usage.Buckets {
labels := map[string]string{"bucket": bucket.Name}
setMetric(t.b2BucketObjects, labels, float64(bucket.ObjectCount))
setMetric(t.b2BucketBytes, labels, float64(bucket.TotalBytes))
setMetric(t.b2BucketRecentObjects, labels, float64(bucket.RecentObjects24h))
setMetric(t.b2BucketRecentBytes, labels, float64(bucket.RecentBytes24h))
if bucket.LastModifiedAt == "" {
continue
}
if ts, err := time.Parse(time.RFC3339, bucket.LastModifiedAt); err == nil {
setMetric(t.b2BucketLastModified, labels, float64(ts.Unix()))
}
}
}
func (t *telemetry) render() string {
t.mu.RLock()
defer t.mu.RUnlock()
@ -147,8 +230,24 @@ func (t *telemetry) render() string {
writeMetricFamily(&b, "soteria_inventory_refresh_timestamp_seconds", "gauge", "Unix timestamp of the last successful inventory refresh.", []metricSample{{value: t.inventoryRefreshTime}})
writeMetricFamily(&b, "pvc_backup_age_hours", "gauge", "Age in hours of the latest successful PVC backup known to Soteria.", metricValues(t.pvcBackupAgeHours))
writeMetricFamily(&b, "pvc_backup_health", "gauge", "PVC backup health according to Soteria: 1=fresh backup within policy, 0=missing/stale/error.", metricValues(t.pvcBackupHealth))
writeMetricFamily(&b, "pvc_backup_health_reason", "gauge", "PVC backup health reason marker with reason label set to 1.", metricValues(t.pvcBackupHealthReason))
writeMetricFamily(&b, "pvc_backup_last_success_timestamp_seconds", "gauge", "Unix timestamp of the latest successful PVC backup known to Soteria.", metricValues(t.pvcBackupLastSuccess))
writeMetricFamily(&b, "pvc_backup_count", "gauge", "Count of backup records discovered for a PVC.", metricValues(t.pvcBackupCount))
writeMetricFamily(&b, "pvc_backup_completed_count", "gauge", "Count of completed backup records discovered for a PVC.", metricValues(t.pvcBackupCompletedCount))
writeMetricFamily(&b, "pvc_backup_last_size_bytes", "gauge", "Size in bytes of the latest completed backup for a PVC.", metricValues(t.pvcBackupLastSizeBytes))
writeMetricFamily(&b, "pvc_backup_total_size_bytes", "gauge", "Total bytes across discovered backup records for a PVC.", metricValues(t.pvcBackupTotalSizeBytes))
writeMetricFamily(&b, "soteria_b2_scan_success", "gauge", "Whether the latest B2 consumption scan succeeded (1) or failed (0).", []metricSample{{value: t.b2ScanSuccess}})
writeMetricFamily(&b, "soteria_b2_scan_timestamp_seconds", "gauge", "Unix timestamp of the latest B2 consumption scan attempt.", []metricSample{{value: t.b2ScanTimestamp}})
writeMetricFamily(&b, "soteria_b2_scan_duration_seconds", "gauge", "Duration in seconds of the latest B2 consumption scan attempt.", []metricSample{{value: t.b2ScanDurationSeconds}})
writeMetricFamily(&b, "soteria_b2_account_objects", "gauge", "Total object count discovered across scanned B2 buckets.", []metricSample{{value: t.b2AccountObjects}})
writeMetricFamily(&b, "soteria_b2_account_bytes", "gauge", "Total stored bytes discovered across scanned B2 buckets.", []metricSample{{value: t.b2AccountBytes}})
writeMetricFamily(&b, "soteria_b2_account_recent_objects_24h", "gauge", "Object count with LastModified in the last 24h across scanned B2 buckets.", []metricSample{{value: t.b2AccountRecentObjects}})
writeMetricFamily(&b, "soteria_b2_account_recent_bytes_24h", "gauge", "Bytes with LastModified in the last 24h across scanned B2 buckets.", []metricSample{{value: t.b2AccountRecentBytes}})
writeMetricFamily(&b, "soteria_b2_bucket_objects", "gauge", "Object count discovered per scanned B2 bucket.", metricValues(t.b2BucketObjects))
writeMetricFamily(&b, "soteria_b2_bucket_bytes", "gauge", "Stored bytes discovered per scanned B2 bucket.", metricValues(t.b2BucketBytes))
writeMetricFamily(&b, "soteria_b2_bucket_recent_objects_24h", "gauge", "Object count with LastModified in the last 24h per scanned B2 bucket.", metricValues(t.b2BucketRecentObjects))
writeMetricFamily(&b, "soteria_b2_bucket_recent_bytes_24h", "gauge", "Bytes with LastModified in the last 24h per scanned B2 bucket.", metricValues(t.b2BucketRecentBytes))
writeMetricFamily(&b, "soteria_b2_bucket_last_modified_timestamp_seconds", "gauge", "Unix timestamp of the most recent object observed in each scanned B2 bucket.", metricValues(t.b2BucketLastModified))
return b.String()
}

View File

@ -9,6 +9,7 @@ import (
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
@ -19,6 +20,7 @@ import (
"scm.bstein.dev/bstein/soteria/internal/longhorn"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
)
@ -48,10 +50,13 @@ type Server struct {
longhorn longhornClient
metrics *telemetry
handler http.Handler
ui *uiRenderer
policyMu sync.RWMutex
policies map[string]api.BackupPolicy
runMu sync.Mutex
running bool
b2Mu sync.RWMutex
b2Usage api.B2UsageResponse
}
type authIdentity struct {
@ -77,6 +82,7 @@ func New(cfg *config.Config, client *k8s.Client, lh *longhorn.Client) *Server {
client: client,
longhorn: lh,
metrics: newTelemetry(),
ui: newUIRenderer(),
policies: map[string]api.BackupPolicy{},
}
s.handler = http.HandlerFunc(s.route)
@ -89,13 +95,23 @@ func (s *Server) Start(ctx context.Context) {
}
s.refreshTelemetry(ctx)
s.refreshB2Usage(ctx)
s.runPolicyCycle(ctx)
metricsTicker := time.NewTicker(s.cfg.MetricsRefreshInterval)
policyTicker := time.NewTicker(s.cfg.PolicyEvalInterval)
var b2Ticker *time.Ticker
var b2Tick <-chan time.Time
if s.cfg.B2Enabled {
b2Ticker = time.NewTicker(s.cfg.B2ScanInterval)
b2Tick = b2Ticker.C
}
go func() {
defer metricsTicker.Stop()
defer policyTicker.Stop()
if b2Ticker != nil {
defer b2Ticker.Stop()
}
for {
select {
case <-ctx.Done():
@ -104,6 +120,8 @@ func (s *Server) Start(ctx context.Context) {
s.refreshTelemetry(ctx)
case <-policyTicker.C:
s.runPolicyCycle(ctx)
case <-b2Tick:
s.refreshB2Usage(ctx)
}
}
}()
@ -138,6 +156,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
s.handleUI(w, r)
case "/v1/b2":
s.handleB2Usage(w, r)
case "/v1/whoami":
s.handleWhoAmI(w, r)
case "/v1/inventory":
@ -155,10 +175,19 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
case "/v1/policies":
s.handlePolicies(w, r)
default:
if s.ui != nil && s.ui.ServeAsset(w, r) {
return
}
if strings.HasPrefix(r.URL.Path, "/v1/policies/") {
s.handlePolicyByID(w, r)
return
}
// Serve SPA index for deep links (for example /backup) while preserving
// explicit API and asset 404 behavior.
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/v1/") && !strings.Contains(r.URL.Path, ".") {
s.handleUI(w, r)
return
}
writeError(w, http.StatusNotFound, "not found")
}
}
@ -176,8 +205,13 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(uiHTML))
if s.ui == nil {
writeError(w, http.StatusInternalServerError, "UI renderer is unavailable")
return
}
if err := s.ui.ServeIndex(w, r); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
}
}
func (s *Server) handleWhoAmI(w http.ResponseWriter, r *http.Request) {
@ -838,6 +872,16 @@ func (s *Server) enrichPVCInventory(ctx context.Context, entry *api.PVCInventory
return
}
entry.BackupCount = len(backups)
totalBackupSize := int64(0)
completedBackups := 0
for _, backup := range backups {
totalBackupSize += parseSizeBytes(backup.Size)
if strings.EqualFold(backup.State, "Completed") {
completedBackups++
}
}
entry.CompletedBackups = completedBackups
entry.TotalBackupSizeBytes = float64(totalBackupSize)
latest, latestTime, ok := latestCompletedBackup(backups)
if !ok {
entry.Healthy = false
@ -849,6 +893,7 @@ func (s *Server) enrichPVCInventory(ctx context.Context, entry *api.PVCInventory
return
}
entry.LastBackupAt = latest.Created
entry.LastBackupSizeBytes = float64(parseSizeBytes(latest.Size))
if latestTime.IsZero() {
entry.Healthy = false
entry.HealthReason = "unknown_timestamp"
@ -1434,3 +1479,23 @@ func sanitizeName(value string) string {
func roundHours(value float64) float64 {
return math.Round(value*100) / 100
}
func parseSizeBytes(raw string) int64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0
}
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
return value
}
if value, err := strconv.ParseFloat(raw, 64); err == nil {
if value < 0 {
return 0
}
return int64(value)
}
if quantity, err := resource.ParseQuantity(raw); err == nil {
return quantity.Value()
}
return 0
}

View File

@ -243,6 +243,47 @@ func TestMetricsStayPublic(t *testing.T) {
}
}
func TestUIFallbackHandlesDeepLinks(t *testing.T) {
srv := &Server{
cfg: &config.Config{AuthRequired: false},
client: &fakeKubeClient{},
longhorn: &fakeLonghornClient{},
metrics: newTelemetry(),
ui: newUIRenderer(),
}
srv.handler = http.HandlerFunc(srv.route)
req := httptest.NewRequest(http.MethodGet, "/backup", nil)
res := httptest.NewRecorder()
srv.Handler().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", res.Code, res.Body.String())
}
if !strings.Contains(res.Body.String(), `<div id="root"></div>`) {
t.Fatalf("expected UI index body, got %q", res.Body.String())
}
}
func TestUIFallbackDoesNotMaskMissingAssets(t *testing.T) {
srv := &Server{
cfg: &config.Config{AuthRequired: false},
client: &fakeKubeClient{},
longhorn: &fakeLonghornClient{},
metrics: newTelemetry(),
ui: newUIRenderer(),
}
srv.handler = http.HandlerFunc(srv.route)
req := httptest.NewRequest(http.MethodGet, "/assets/missing.js", nil)
res := httptest.NewRecorder()
srv.Handler().ServeHTTP(res, req)
if res.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", res.Code, res.Body.String())
}
}
func TestPoliciesCRUD(t *testing.T) {
srv := &Server{
cfg: &config.Config{AuthRequired: false, BackupDriver: "longhorn", Namespace: "maintenance", PolicySecretName: "soteria-policies"},

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
:root{color-scheme:dark;--bg: #090d14;--bg-alt: #101726;--card: #131e31;--card-alt: #17263b;--line: #23324d;--text: #e8efff;--muted: #9eb2d8;--accent: #3ea7ff;--accent-soft: #203759;--good: #48c88e;--bad: #ff6a78;--warn: #f1b45a;--shadow: rgba(4, 8, 15, .4)}*{box-sizing:border-box}body{margin:0;font-family:IBM Plex Sans,Segoe UI,sans-serif;color:var(--text);background:radial-gradient(1200px 500px at 20% -20%,#1f3656 0%,transparent 60%),radial-gradient(1000px 700px at 120% 10%,#1f2f4e 0%,transparent 50%),var(--bg)}h1,h2,h3,h4,p{margin:0}button,input,select{font:inherit}.app-shell{min-height:100vh;padding:20px}.topbar{max-width:1780px;margin:0 auto 18px;padding:18px 20px;border:1px solid var(--line);border-radius:16px;background:linear-gradient(150deg,#131e31f5,#0c1421f5);box-shadow:0 16px 34px var(--shadow);display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap}.topbar h1{font-size:clamp(1.35rem,1.7vw,1.9rem);letter-spacing:.02em}.subtle{color:var(--muted)}.tiny{font-size:.84rem}.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.layout{max-width:1780px;margin:0 auto;display:grid;grid-template-columns:minmax(430px,1.5fr) minmax(360px,1fr) minmax(380px,1.05fr);gap:16px;align-items:start}.column{display:grid;gap:16px}.panel{border:1px solid var(--line);border-radius:16px;background:linear-gradient(150deg,#131e31f2,#101726f5);box-shadow:0 12px 30px var(--shadow);padding:14px;display:grid;gap:12px}.scroll-panel{max-height:calc(100vh - 160px);overflow:auto}.panel-header{display:flex;justify-content:space-between;align-items:center;gap:12px}.chip{border-radius:999px;border:1px solid var(--line);padding:4px 10px;font-size:.82rem;background:var(--bg-alt);color:var(--muted);display:inline-flex;align-items:center;white-space:nowrap}.chip.good{border-color:#48c88e99;color:var(--good);background:#48c88e1c}.chip.bad{border-color:#ff6a7899;color:var(--bad);background:#ff6a781f}.chip.warn{border-color:#f1b45a99;color:var(--warn);background:#f1b45a1f}button{border:1px solid transparent;border-radius:10px;padding:7px 12px;cursor:pointer;color:#091220;background:var(--accent);font-weight:600}button.secondary{border-color:var(--line);color:var(--text);background:var(--accent-soft)}button:disabled{opacity:.65;cursor:wait}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.error{color:var(--bad);font-weight:600}.namespace-block{border:1px solid rgba(35,50,77,.65);border-radius:14px;padding:10px;display:grid;gap:10px;background:#0c121ea6}.namespace-row{display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap}.namespace-row h3{font-size:1.03rem}.pvc-grid{display:grid;gap:9px}.pvc-card{border:1px solid rgba(35,50,77,.8);border-radius:12px;background:linear-gradient(155deg,#17263beb,#0f1929eb);padding:10px;display:grid;gap:8px}.pvc-title-row{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.pvc-title-row h4{font-size:.98rem}label{display:grid;gap:6px;font-size:.9rem;color:var(--muted)}input,select{border:1px solid var(--line);border-radius:10px;color:var(--text);background:#090d14cc;padding:8px 10px}.stack{display:grid;gap:10px}pre{margin:0;border-radius:12px;border:1px solid var(--line);background:#060a10fa;color:#dbf0ff;padding:10px;max-height:290px;overflow:auto;font-size:.82rem}.action-panel{min-height:280px}.stat-grid{display:grid;gap:8px;grid-template-columns:repeat(2,minmax(0,1fr))}.stat{border:1px solid var(--line);border-radius:10px;background:#0d1827db;padding:8px;display:grid;gap:4px}.stat .label{color:var(--muted);font-size:.78rem}.bucket-table-wrap{overflow:auto;max-height:270px;border:1px solid var(--line);border-radius:10px}table{width:100%;border-collapse:collapse;font-size:.85rem}th,td{text-align:left;padding:8px;border-bottom:1px solid rgba(35,50,77,.6);vertical-align:top}th{position:sticky;top:0;background:#0a101cf5}.policy-list{display:grid;gap:8px}.policy-item{border:1px solid rgba(35,50,77,.8);border-radius:11px;background:#0b121fc7;padding:9px;display:grid;gap:7px}.policy-head{display:flex;justify-content:space-between;align-items:center;gap:10px}.checkbox-row{display:flex;align-items:center;gap:8px;color:var(--text)}@media (max-width: 1420px){.layout{grid-template-columns:minmax(420px,1.5fr) minmax(330px,1fr)}.layout>.column:last-child{grid-column:1 / -1;grid-template-columns:repeat(2,minmax(0,1fr))}}@media (max-width: 1080px){.app-shell{padding:12px}.layout{grid-template-columns:1fr}.layout>.column:last-child{grid-template-columns:1fr}.scroll-panel{max-height:none}.stat-grid{grid-template-columns:1fr}}

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Soteria Backup Console</title>
<script type="module" crossorigin src="/assets/index-Bl8gBoZ6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dq7_oHb5.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -1,6 +0,0 @@
package server
import _ "embed"
//go:embed ui.html
var uiHTML string

View File

@ -1,574 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Soteria Backup Console</title>
<style>
:root {
color-scheme: light;
--bg: #f4efe4;
--card: #fffaf1;
--ink: #1d1d1b;
--muted: #5f625b;
--line: #d8cdb8;
--accent: #0f766e;
--warn: #c2410c;
--good: #166534;
--bad: #991b1b;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Iowan Old Style", "Palatino Linotype", serif;
color: var(--ink);
background: radial-gradient(circle at top right, #f9e8c8 0, var(--bg) 45%), var(--bg);
}
header {
padding: 24px;
border-bottom: 1px solid var(--line);
background: rgba(255,250,241,0.92);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
h1 { margin: 0 0 6px; font-size: 2rem; }
.sub { color: var(--muted); margin: 0; }
.topbar, main {
width: min(1200px, calc(100vw - 32px));
margin: 0 auto;
}
.topbar {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
main {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 20px;
padding: 20px 0 40px;
}
.panel {
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
box-shadow: 0 10px 24px rgba(38, 35, 25, 0.06);
}
.panel h2, .panel h3 { margin-top: 0; }
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 999px;
padding: 6px 10px;
font-size: 0.92rem;
background: #efe7d6;
}
.badge.good { background: #dcfce7; color: var(--good); }
.badge.bad { background: #fee2e2; color: var(--bad); }
button {
border: 0;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
cursor: pointer;
background: var(--accent);
color: white;
}
button.secondary {
background: #efe7d6;
color: var(--ink);
}
button:disabled { opacity: 0.6; cursor: wait; }
.namespace {
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px dashed var(--line);
}
.namespace:last-child { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; }
.pvc {
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px;
margin: 10px 0;
background: rgba(255,255,255,0.55);
}
.row, .actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.meta {
color: var(--muted);
font-size: 0.95rem;
}
.stack { display: grid; gap: 12px; }
label { display: grid; gap: 6px; font-weight: 600; }
input, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
font: inherit;
background: white;
}
pre {
margin: 0;
padding: 12px;
border-radius: 12px;
background: #171717;
color: #f7f7f7;
overflow: auto;
font-size: 0.9rem;
}
.muted { color: var(--muted); }
.error { color: var(--bad); }
.policy-item {
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px 12px;
background: rgba(255,255,255,0.65);
}
.mono {
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.88rem;
}
@media (max-width: 900px) {
main { grid-template-columns: 1fr; }
h1 { font-size: 1.7rem; }
}
</style>
</head>
<body>
<header>
<div class="topbar">
<div>
<h1>Soteria Backup Console</h1>
<p class="sub">Namespace-grouped PVC backup and restore control plane for Atlas.</p>
</div>
<div class="row">
<span id="auth-pill" class="badge">Checking access...</span>
<button id="refresh-btn" class="secondary">Refresh inventory</button>
</div>
</div>
</header>
<main>
<section class="panel">
<div class="row" style="justify-content: space-between; margin-bottom: 12px;">
<h2 style="margin-bottom: 0;">PVC Inventory</h2>
<span id="generated-at" class="muted"></span>
</div>
<div id="inventory" class="stack">
<p class="muted">Loading PVC inventory...</p>
</div>
</section>
<aside class="stack">
<section class="panel">
<h2>Restore Workspace</h2>
<div id="details" class="muted">Choose a PVC to inspect backups or prepare a restore.</div>
</section>
<section class="panel">
<h2>Last Action</h2>
<pre id="result">No action yet.</pre>
</section>
<section class="panel">
<h2>Backup Policies</h2>
<div class="stack" style="margin-bottom: 12px;">
<label>Namespace<input id="policy-namespace" list="policy-namespace-options" placeholder="apps"></label>
<datalist id="policy-namespace-options"></datalist>
<label>PVC (optional, blank means all PVCs in namespace)<input id="policy-pvc" placeholder="cache-data"></label>
<label>Interval hours<input id="policy-interval" type="number" min="1" step="1" value="24"></label>
<label class="row" style="font-weight: 500;">
<input id="policy-enabled" type="checkbox" checked style="width: auto;">
Enabled
</label>
<button id="policy-save">Save policy</button>
</div>
<div id="policy-list" class="stack">
<p class="muted">Loading policies...</p>
</div>
</section>
</aside>
</main>
<script>
const inventoryEl = document.getElementById('inventory');
const detailsEl = document.getElementById('details');
const resultEl = document.getElementById('result');
const generatedAtEl = document.getElementById('generated-at');
const authPillEl = document.getElementById('auth-pill');
const refreshBtn = document.getElementById('refresh-btn');
const policyListEl = document.getElementById('policy-list');
const policyNamespaceEl = document.getElementById('policy-namespace');
const policyNamespaceOptionsEl = document.getElementById('policy-namespace-options');
const policyPVCEl = document.getElementById('policy-pvc');
const policyIntervalEl = document.getElementById('policy-interval');
const policyEnabledEl = document.getElementById('policy-enabled');
const policySaveBtn = document.getElementById('policy-save');
let latestInventory = null;
let latestPolicies = [];
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function showResult(payload) {
resultEl.textContent = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
}
function suggestTargetPVCName(sourcePVC) {
const now = new Date();
const pad = (value) => String(value).padStart(2, '0');
const suffix = [
now.getUTCFullYear(),
pad(now.getUTCMonth() + 1),
pad(now.getUTCDate()),
pad(now.getUTCHours()),
pad(now.getUTCMinutes())
].join('');
const normalized = ('restore-' + sourcePVC + '-' + suffix)
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const trimmed = normalized.length <= 63 ? normalized : normalized.slice(0, 63).replace(/-+$/g, '');
return trimmed || 'restore-' + suffix;
}
function suggestNamespaceRestorePrefix() {
const now = new Date();
const pad = (value) => String(value).padStart(2, '0');
const suffix = [
now.getUTCFullYear(),
pad(now.getUTCMonth() + 1),
pad(now.getUTCDate()),
pad(now.getUTCHours()),
pad(now.getUTCMinutes())
].join('');
return 'restore-' + suffix + '-';
}
async function fetchJSON(url, options) {
const response = await fetch(url, options);
const text = await response.text();
let payload = text;
try { payload = text ? JSON.parse(text) : {}; } catch (_) {}
if (!response.ok) {
const message = payload && payload.error ? payload.error : response.status + ' ' + response.statusText;
throw new Error(message);
}
return payload;
}
async function loadWhoAmI() {
try {
const who = await fetchJSON('/v1/whoami');
const label = who.authenticated
? (who.user || who.email || 'authenticated') + ' (' + ((who.groups || []).join(', ') || 'no groups') + ')'
: 'anonymous';
authPillEl.textContent = label;
authPillEl.className = 'badge ' + (who.authenticated ? 'good' : '');
} catch (error) {
authPillEl.textContent = error.message;
authPillEl.className = 'badge bad';
}
}
async function triggerBackup(namespace, pvc) {
try {
const payload = await fetchJSON('/v1/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace, pvc, dry_run: false })
});
showResult(payload);
await loadInventory();
} catch (error) {
showResult({ error: error.message, namespace, pvc });
}
}
async function triggerNamespaceBackup(namespace, dryRun) {
try {
const payload = await fetchJSON('/v1/backup/namespace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace, dry_run: dryRun })
});
showResult(payload);
await loadInventory();
} catch (error) {
showResult({ error: error.message, namespace, dry_run: dryRun });
}
}
async function showNamespaceRestore(namespace) {
const namespaceOptions = (latestInventory && latestInventory.namespaces ? latestInventory.namespaces : [])
.map((group) => '<option value="' + escapeHtml(group.name) + '"></option>')
.join('');
detailsEl.innerHTML = [
'<div class="stack">',
'<div><h3 style="margin-bottom: 6px;">Namespace restore</h3><p class="muted" style="margin-top: 0;">Source namespace: ' + escapeHtml(namespace) + '</p></div>',
'<label>Target namespace<input id="namespace-restore-target" list="namespace-restore-options" value="' + escapeHtml(namespace) + '"></label>',
'<datalist id="namespace-restore-options">' + namespaceOptions + '</datalist>',
'<label>Target PVC prefix<input id="namespace-restore-prefix" value="' + escapeHtml(suggestNamespaceRestorePrefix()) + '"></label>',
'<label>Snapshot hint (optional, blank = latest completed per PVC)<input id="namespace-restore-snapshot" placeholder="blank uses latest"></label>',
'<p class="muted">A target PVC will be created for each source PVC using this prefix.</p>',
'<div class="actions">',
'<button id="namespace-restore-run">Create restore PVCs</button>',
'<button id="namespace-restore-dry" class="secondary">Dry run namespace restore</button>',
'</div></div>'
].join('');
const runRestore = async (dryRun) => {
const targetNamespace = document.getElementById('namespace-restore-target').value.trim();
const targetPrefix = document.getElementById('namespace-restore-prefix').value.trim();
const snapshot = document.getElementById('namespace-restore-snapshot').value.trim();
try {
const payload = await fetchJSON('/v1/restores/namespace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
namespace,
target_namespace: targetNamespace,
target_prefix: targetPrefix,
snapshot,
dry_run: dryRun
})
});
showResult(payload);
await loadInventory();
} catch (error) {
showResult({ error: error.message, namespace, target_namespace: targetNamespace, target_prefix: targetPrefix, dry_run: dryRun });
}
};
document.getElementById('namespace-restore-run').onclick = () => runRestore(false);
document.getElementById('namespace-restore-dry').onclick = () => runRestore(true);
}
async function showRestore(namespace, pvc) {
detailsEl.innerHTML = '<p class="muted">Loading backups...</p>';
try {
const payload = await fetchJSON('/v1/backups?namespace=' + encodeURIComponent(namespace) + '&pvc=' + encodeURIComponent(pvc));
const namespaceOptions = (latestInventory && latestInventory.namespaces ? latestInventory.namespaces : [])
.map((group) => '<option value="' + escapeHtml(group.name) + '"></option>')
.join('');
const options = payload.backups
.filter((backup) => backup.state === 'Completed')
.map((backup) => '<option value="' + escapeHtml(backup.url) + '">' + escapeHtml(backup.name) + ' | ' + escapeHtml(backup.created || 'unknown time') + '</option>')
.join('');
detailsEl.innerHTML = [
'<div class="stack">',
'<div><h3 style="margin-bottom: 6px;">' + escapeHtml(namespace) + '/' + escapeHtml(pvc) + '</h3><p class="muted" style="margin-top: 0;">Source volume: ' + escapeHtml(payload.volume) + '</p></div>',
'<label>Backup snapshot<select id="restore-backup">' + (options || '<option value="">No completed backups available</option>') + '</select></label>',
'<label>Target namespace<input id="restore-namespace" list="restore-namespace-options" value="' + escapeHtml(namespace) + '"></label>',
'<datalist id="restore-namespace-options">' + namespaceOptions + '</datalist>',
'<label>Target PVC name<input id="restore-pvc" value="' + escapeHtml(suggestTargetPVCName(pvc)) + '"></label>',
'<p class="muted">Tip: keep target PVC unique for each restore drill to avoid conflicts.</p>',
'<div class="actions">',
'<button id="restore-submit"' + (options ? '' : ' disabled') + '>Create restore PVC</button>',
'<button id="restore-dry-run" class="secondary"' + (options ? '' : ' disabled') + '>Dry run restore</button>',
'<button id="restore-view" class="secondary">Show backup JSON</button>',
'</div></div>'
].join('');
const runRestore = async (dryRun) => {
const backupURL = document.getElementById('restore-backup').value;
const targetNamespace = document.getElementById('restore-namespace').value.trim();
const targetPVC = document.getElementById('restore-pvc').value.trim();
try {
const result = await fetchJSON('/v1/restores', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
namespace,
pvc,
backup_url: backupURL,
target_namespace: targetNamespace,
target_pvc: targetPVC,
dry_run: dryRun
})
});
showResult(result);
await loadInventory();
} catch (error) {
showResult({ error: error.message, namespace, pvc, target_namespace: targetNamespace, target_pvc: targetPVC, dry_run: dryRun });
}
};
document.getElementById('restore-submit').onclick = () => runRestore(false);
document.getElementById('restore-dry-run').onclick = () => runRestore(true);
document.getElementById('restore-view').onclick = () => showResult(payload);
} catch (error) {
detailsEl.innerHTML = '<p class="error">' + escapeHtml(error.message) + '</p>';
}
}
function renderPolicies(payload) {
latestPolicies = payload && payload.policies ? payload.policies : [];
if (!latestPolicies.length) {
policyListEl.innerHTML = '<p class="muted">No policies yet. Add one to enable scheduled backups.</p>';
return;
}
policyListEl.innerHTML = latestPolicies.map((policy) => {
const scope = policy.pvc ? (policy.namespace + '/' + policy.pvc) : (policy.namespace + '/*');
return [
'<article class="policy-item stack">',
'<div class="row" style="justify-content: space-between;">',
'<span class="mono">' + escapeHtml(scope) + '</span>',
'<span class="badge ' + (policy.enabled ? 'good' : 'bad') + '">' + (policy.enabled ? 'Enabled' : 'Disabled') + '</span>',
'</div>',
'<div class="meta">Every ' + escapeHtml(policy.interval_hours) + 'h</div>',
'<div class="actions">',
'<button class="secondary" data-action="policy-apply" data-namespace="' + escapeHtml(policy.namespace) + '" data-pvc="' + escapeHtml(policy.pvc || '') + '" data-interval="' + escapeHtml(policy.interval_hours) + '" data-enabled="' + escapeHtml(policy.enabled) + '">Load</button>',
'<button class="secondary" data-action="policy-delete" data-policy-id="' + escapeHtml(policy.id) + '">Delete</button>',
'</div>',
'</article>'
].join('');
}).join('');
policyListEl.querySelectorAll('button[data-action="policy-delete"]').forEach((button) => {
button.addEventListener('click', () => deletePolicy(button.dataset.policyId));
});
policyListEl.querySelectorAll('button[data-action="policy-apply"]').forEach((button) => {
button.addEventListener('click', () => {
policyNamespaceEl.value = button.dataset.namespace || '';
policyPVCEl.value = button.dataset.pvc || '';
policyIntervalEl.value = button.dataset.interval || '24';
policyEnabledEl.checked = String(button.dataset.enabled) === 'true';
});
});
}
async function loadPolicies() {
policyListEl.innerHTML = '<p class="muted">Refreshing policies...</p>';
try {
const payload = await fetchJSON('/v1/policies');
renderPolicies(payload);
} catch (error) {
policyListEl.innerHTML = '<p class="error">' + escapeHtml(error.message) + '</p>';
}
}
async function savePolicy() {
const namespace = policyNamespaceEl.value.trim();
const pvc = policyPVCEl.value.trim();
const intervalHours = Number(policyIntervalEl.value);
const enabled = Boolean(policyEnabledEl.checked);
try {
const payload = await fetchJSON('/v1/policies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
namespace,
pvc,
interval_hours: intervalHours,
enabled
})
});
showResult(payload);
await loadPolicies();
} catch (error) {
showResult({ error: error.message, namespace, pvc, interval_hours: intervalHours, enabled });
}
}
async function deletePolicy(policyID) {
try {
const payload = await fetchJSON('/v1/policies/' + encodeURIComponent(policyID), { method: 'DELETE' });
showResult(payload);
await loadPolicies();
} catch (error) {
showResult({ error: error.message, policy_id: policyID });
}
}
function renderInventory(payload) {
latestInventory = payload;
generatedAtEl.textContent = payload.generated_at ? 'Updated ' + payload.generated_at : '';
policyNamespaceOptionsEl.innerHTML = (payload.namespaces || [])
.map((group) => '<option value="' + escapeHtml(group.name) + '"></option>')
.join('');
if (!payload.namespaces || payload.namespaces.length === 0) {
inventoryEl.innerHTML = '<p class="muted">No bound PVCs found.</p>';
return;
}
inventoryEl.innerHTML = payload.namespaces.map((group) => {
const pvcs = group.pvcs.map((pvc) => {
const healthClass = pvc.healthy ? 'good' : 'bad';
const healthText = pvc.healthy ? 'Healthy backup window' : (pvc.health_reason || 'Needs attention');
const ageText = pvc.last_backup_at
? Number(pvc.last_backup_age_hours || 0).toFixed(1) + 'h since last success'
: 'No successful backup yet';
return [
'<article class="pvc">',
'<div class="row" style="justify-content: space-between; align-items: flex-start;">',
'<div><h3 style="margin: 0 0 4px;">' + escapeHtml(pvc.pvc) + '</h3><div class="meta">' + escapeHtml(pvc.volume) + ' • ' + escapeHtml(pvc.storage_class || 'no storage class') + ' • ' + escapeHtml(pvc.capacity || 'size unknown') + '</div></div>',
'<span class="badge ' + healthClass + '">' + escapeHtml(healthText) + '</span>',
'</div>',
'<p class="meta">' + escapeHtml(ageText) + (pvc.error ? ' • ' + escapeHtml(pvc.error) : '') + '</p>',
'<div class="actions">',
'<button data-action="backup" data-namespace="' + escapeHtml(pvc.namespace) + '" data-pvc="' + escapeHtml(pvc.pvc) + '">Backup now</button>',
'<button class="secondary" data-action="restore" data-namespace="' + escapeHtml(pvc.namespace) + '" data-pvc="' + escapeHtml(pvc.pvc) + '">Restore</button>',
'</div></article>'
].join('');
}).join('');
return [
'<section class="namespace">',
'<div class="row" style="justify-content: space-between; align-items: flex-start;">',
'<h3 style="margin: 0;">' + escapeHtml(group.name) + '</h3>',
'<div class="actions">',
'<button class="secondary" data-action="backup-namespace" data-namespace="' + escapeHtml(group.name) + '">Backup namespace</button>',
'<button class="secondary" data-action="restore-namespace" data-namespace="' + escapeHtml(group.name) + '">Restore namespace</button>',
'</div>',
'</div>',
pvcs,
'</section>'
].join('');
}).join('');
inventoryEl.querySelectorAll('button[data-action="backup"]').forEach((button) => {
button.addEventListener('click', () => triggerBackup(button.dataset.namespace, button.dataset.pvc));
});
inventoryEl.querySelectorAll('button[data-action="restore"]').forEach((button) => {
button.addEventListener('click', () => showRestore(button.dataset.namespace, button.dataset.pvc));
});
inventoryEl.querySelectorAll('button[data-action="backup-namespace"]').forEach((button) => {
button.addEventListener('click', () => triggerNamespaceBackup(button.dataset.namespace, false));
});
inventoryEl.querySelectorAll('button[data-action="restore-namespace"]').forEach((button) => {
button.addEventListener('click', () => showNamespaceRestore(button.dataset.namespace));
});
}
async function loadInventory() {
refreshBtn.disabled = true;
inventoryEl.innerHTML = '<p class="muted">Refreshing inventory...</p>';
try {
const payload = await fetchJSON('/v1/inventory');
renderInventory(payload);
} catch (error) {
inventoryEl.innerHTML = '<p class="error">' + escapeHtml(error.message) + '</p>';
} finally {
refreshBtn.disabled = false;
}
}
refreshBtn.addEventListener('click', loadInventory);
policySaveBtn.addEventListener('click', savePolicy);
loadWhoAmI();
loadInventory();
loadPolicies();
</script>
</body>
</html>

View File

@ -0,0 +1,69 @@
package server
import (
"embed"
"fmt"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed ui-dist/*
var uiDist embed.FS
type uiRenderer struct {
fsys fs.FS
}
func newUIRenderer() *uiRenderer {
sub, err := fs.Sub(uiDist, "ui-dist")
if err != nil {
return &uiRenderer{}
}
return &uiRenderer{fsys: sub}
}
func (u *uiRenderer) ServeIndex(w http.ResponseWriter, _ *http.Request) error {
if u.fsys == nil {
return fmt.Errorf("UI assets are not available")
}
body, err := fs.ReadFile(u.fsys, "index.html")
if err != nil {
return fmt.Errorf("read index: %w", err)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(body)
return nil
}
func (u *uiRenderer) ServeAsset(w http.ResponseWriter, r *http.Request) bool {
if u.fsys == nil {
return false
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return false
}
requestPath := strings.TrimPrefix(r.URL.Path, "/")
if requestPath == "" || requestPath == "." {
return false
}
if strings.HasPrefix(requestPath, "v1/") {
return false
}
cleaned := path.Clean(requestPath)
if cleaned == "." || strings.HasPrefix(cleaned, "../") {
return false
}
if strings.HasSuffix(cleaned, "/") {
return false
}
if _, err := fs.Stat(u.fsys, cleaned); err != nil {
return false
}
http.FileServer(http.FS(u.fsys)).ServeHTTP(w, r)
return true
}

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Soteria Backup Console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1729
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
web/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "soteria-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^5.4.10"
}
}

732
web/src/App.tsx Normal file
View File

@ -0,0 +1,732 @@
import { useEffect, useMemo, useState } from 'react';
import './styles.css';
interface AuthInfo {
authenticated: boolean;
user?: string;
email?: string;
groups?: string[];
}
interface BackupRecord {
name: string;
snapshot_name?: string;
created?: string;
state?: string;
url?: string;
size?: string;
latest?: boolean;
}
interface BackupListResponse {
namespace: string;
pvc: string;
volume: string;
backups: BackupRecord[];
}
interface PVCInventory {
namespace: string;
pvc: string;
volume?: string;
phase?: string;
storage_class?: string;
capacity?: string;
access_modes?: string[];
driver?: string;
last_backup_at?: string;
last_backup_age_hours?: number;
backup_count: number;
completed_backups: number;
last_backup_size_bytes?: number;
total_backup_size_bytes?: number;
healthy: boolean;
health_reason?: string;
error?: string;
}
interface NamespaceInventory {
name: string;
pvcs: PVCInventory[];
}
interface InventoryResponse {
generated_at: string;
namespaces: NamespaceInventory[];
}
interface BackupPolicy {
id: string;
namespace: string;
pvc?: string;
interval_hours: number;
enabled: boolean;
created_at?: string;
updated_at?: string;
}
interface BackupPolicyListResponse {
policies: BackupPolicy[];
}
interface B2BucketUsage {
name: string;
object_count: number;
total_bytes: number;
recent_objects_24h: number;
recent_bytes_24h: number;
last_modified_at?: string;
}
interface B2UsageResponse {
enabled: boolean;
available: boolean;
endpoint?: string;
region?: string;
scanned_at?: string;
scan_duration_ms?: number;
total_objects: number;
total_bytes: number;
recent_objects_24h: number;
recent_bytes_24h: number;
buckets?: B2BucketUsage[];
error?: string;
}
type RestoreSelection =
| { kind: 'none' }
| { kind: 'pvc'; namespace: string; pvc: string; volume: string; backups: BackupRecord[] }
| { kind: 'namespace'; namespace: string };
const EMPTY_B2: B2UsageResponse = {
enabled: false,
available: false,
total_objects: 0,
total_bytes: 0,
recent_objects_24h: 0,
recent_bytes_24h: 0,
buckets: []
};
async function fetchJSON<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
const text = await response.text();
let payload: unknown = {};
if (text.trim() !== '') {
try {
payload = JSON.parse(text);
} catch {
payload = { error: text };
}
}
if (!response.ok) {
const message = typeof payload === 'object' && payload !== null && 'error' in payload
? String((payload as { error: unknown }).error)
: `${response.status} ${response.statusText}`;
throw new Error(message);
}
return payload as T;
}
function formatBytes(value?: number): string {
if (!value || value <= 0) {
return '0 B';
}
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let size = value;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`;
}
function formatTimestamp(value?: string): string {
if (!value) {
return 'n/a';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.valueOf())) {
return value;
}
return parsed.toLocaleString();
}
function suggestTargetPVCName(sourcePVC: string): string {
const now = new Date();
const pad = (item: number) => String(item).padStart(2, '0');
const stamp = [
now.getUTCFullYear(),
pad(now.getUTCMonth() + 1),
pad(now.getUTCDate()),
pad(now.getUTCHours()),
pad(now.getUTCMinutes())
].join('');
return (`restore-${sourcePVC}-${stamp}`)
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 63)
.replace(/-+$/g, '');
}
function suggestNamespacePrefix(): string {
const now = new Date();
const pad = (item: number) => String(item).padStart(2, '0');
const stamp = [
now.getUTCFullYear(),
pad(now.getUTCMonth() + 1),
pad(now.getUTCDate()),
pad(now.getUTCHours()),
pad(now.getUTCMinutes())
].join('');
return `restore-${stamp}-`;
}
function App() {
const [auth, setAuth] = useState<AuthInfo | null>(null);
const [authError, setAuthError] = useState<string>('');
const [inventory, setInventory] = useState<InventoryResponse | null>(null);
const [inventoryError, setInventoryError] = useState<string>('');
const [policies, setPolicies] = useState<BackupPolicy[]>([]);
const [policyError, setPolicyError] = useState<string>('');
const [b2Usage, setB2Usage] = useState<B2UsageResponse>(EMPTY_B2);
const [b2Error, setB2Error] = useState<string>('');
const [selection, setSelection] = useState<RestoreSelection>({ kind: 'none' });
const [restoreNamespace, setRestoreNamespace] = useState<string>('');
const [restorePVC, setRestorePVC] = useState<string>('');
const [restoreBackupURL, setRestoreBackupURL] = useState<string>('');
const [namespaceRestoreTarget, setNamespaceRestoreTarget] = useState<string>('');
const [namespaceRestorePrefix, setNamespaceRestorePrefix] = useState<string>(suggestNamespacePrefix());
const [namespaceRestoreSnapshot, setNamespaceRestoreSnapshot] = useState<string>('');
const [policyNamespace, setPolicyNamespace] = useState<string>('');
const [policyPVC, setPolicyPVC] = useState<string>('');
const [policyIntervalHours, setPolicyIntervalHours] = useState<number>(24);
const [policyEnabled, setPolicyEnabled] = useState<boolean>(true);
const [lastAction, setLastAction] = useState<string>('No action yet.');
const [busy, setBusy] = useState<boolean>(false);
const namespaceOptions = useMemo(() => {
if (!inventory) {
return [] as string[];
}
return inventory.namespaces.map((item) => item.name);
}, [inventory]);
const writeAction = (payload: unknown): void => {
if (typeof payload === 'string') {
setLastAction(payload);
return;
}
setLastAction(JSON.stringify(payload, null, 2));
};
const loadWhoAmI = async (): Promise<void> => {
try {
const who = await fetchJSON<AuthInfo>('/v1/whoami');
setAuth(who);
setAuthError('');
} catch (error) {
setAuth(null);
setAuthError(error instanceof Error ? error.message : 'failed to load auth');
}
};
const loadInventory = async (): Promise<void> => {
try {
const payload = await fetchJSON<InventoryResponse>('/v1/inventory');
setInventory(payload);
setInventoryError('');
if (!policyNamespace && payload.namespaces.length > 0) {
setPolicyNamespace(payload.namespaces[0].name);
}
} catch (error) {
setInventory(null);
setInventoryError(error instanceof Error ? error.message : 'failed to load inventory');
}
};
const loadPolicies = async (): Promise<void> => {
try {
const payload = await fetchJSON<BackupPolicyListResponse>('/v1/policies');
setPolicies(payload.policies || []);
setPolicyError('');
} catch (error) {
setPolicies([]);
setPolicyError(error instanceof Error ? error.message : 'failed to load policies');
}
};
const loadB2Usage = async (): Promise<void> => {
try {
const payload = await fetchJSON<B2UsageResponse>('/v1/b2');
setB2Usage(payload);
setB2Error('');
} catch (error) {
setB2Usage(EMPTY_B2);
setB2Error(error instanceof Error ? error.message : 'failed to load B2 usage');
}
};
const refreshAll = async (): Promise<void> => {
setBusy(true);
try {
await Promise.all([loadWhoAmI(), loadInventory(), loadPolicies(), loadB2Usage()]);
} finally {
setBusy(false);
}
};
useEffect(() => {
void refreshAll();
}, []);
const triggerBackup = async (namespace: string, pvc: string): Promise<void> => {
setBusy(true);
try {
const payload = await fetchJSON<unknown>('/v1/backup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace, pvc, dry_run: false })
});
writeAction(payload);
await Promise.all([loadInventory(), loadB2Usage()]);
} catch (error) {
writeAction({ error: error instanceof Error ? error.message : 'backup request failed', namespace, pvc });
} finally {
setBusy(false);
}
};
const triggerNamespaceBackup = async (namespace: string): Promise<void> => {
setBusy(true);
try {
const payload = await fetchJSON<unknown>('/v1/backup/namespace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ namespace, dry_run: false })
});
writeAction(payload);
await Promise.all([loadInventory(), loadB2Usage()]);
} catch (error) {
writeAction({ error: error instanceof Error ? error.message : 'namespace backup failed', namespace });
} finally {
setBusy(false);
}
};
const openPVCSelection = async (namespace: string, pvc: string): Promise<void> => {
setBusy(true);
try {
const payload = await fetchJSON<BackupListResponse>(`/v1/backups?namespace=${encodeURIComponent(namespace)}&pvc=${encodeURIComponent(pvc)}`);
const completed = payload.backups.filter((item) => item.state === 'Completed' && item.url);
setSelection({ kind: 'pvc', namespace, pvc, volume: payload.volume, backups: completed });
setRestoreNamespace(namespace);
setRestorePVC(suggestTargetPVCName(pvc));
setRestoreBackupURL(completed.length > 0 ? String(completed[0].url) : '');
writeAction(payload);
} catch (error) {
writeAction({ error: error instanceof Error ? error.message : 'failed to load backups', namespace, pvc });
} finally {
setBusy(false);
}
};
const openNamespaceSelection = (namespace: string): void => {
setSelection({ kind: 'namespace', namespace });
setNamespaceRestoreTarget(namespace);
setNamespaceRestorePrefix(suggestNamespacePrefix());
setNamespaceRestoreSnapshot('');
writeAction(`Namespace restore planner loaded for ${namespace}.`);
};
const runPVCRestore = async (dryRun: boolean): Promise<void> => {
if (selection.kind !== 'pvc') {
return;
}
setBusy(true);
try {
const payload = await fetchJSON<unknown>('/v1/restores', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
namespace: selection.namespace,
pvc: selection.pvc,
backup_url: restoreBackupURL,
target_namespace: restoreNamespace,
target_pvc: restorePVC,
dry_run: dryRun
})
});
writeAction(payload);
await Promise.all([loadInventory(), loadB2Usage()]);
} catch (error) {
writeAction({
error: error instanceof Error ? error.message : 'restore failed',
namespace: selection.namespace,
pvc: selection.pvc,
target_namespace: restoreNamespace,
target_pvc: restorePVC,
dry_run: dryRun
});
} finally {
setBusy(false);
}
};
const runNamespaceRestore = async (dryRun: boolean): Promise<void> => {
if (selection.kind !== 'namespace') {
return;
}
setBusy(true);
try {
const payload = await fetchJSON<unknown>('/v1/restores/namespace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
namespace: selection.namespace,
target_namespace: namespaceRestoreTarget,
target_prefix: namespaceRestorePrefix,
snapshot: namespaceRestoreSnapshot,
dry_run: dryRun
})
});
writeAction(payload);
await Promise.all([loadInventory(), loadB2Usage()]);
} catch (error) {
writeAction({
error: error instanceof Error ? error.message : 'namespace restore failed',
namespace: selection.namespace,
target_namespace: namespaceRestoreTarget,
target_prefix: namespaceRestorePrefix,
dry_run: dryRun
});
} finally {
setBusy(false);
}
};
const savePolicy = async (): Promise<void> => {
setBusy(true);
try {
const payload = await fetchJSON<unknown>('/v1/policies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
namespace: policyNamespace,
pvc: policyPVC,
interval_hours: policyIntervalHours,
enabled: policyEnabled
})
});
writeAction(payload);
await Promise.all([loadPolicies(), loadInventory()]);
} catch (error) {
writeAction({ error: error instanceof Error ? error.message : 'policy save failed' });
} finally {
setBusy(false);
}
};
const deletePolicy = async (policyID: string): Promise<void> => {
setBusy(true);
try {
const payload = await fetchJSON<unknown>(`/v1/policies/${encodeURIComponent(policyID)}`, {
method: 'DELETE'
});
writeAction(payload);
await Promise.all([loadPolicies(), loadInventory()]);
} catch (error) {
writeAction({ error: error instanceof Error ? error.message : 'policy delete failed', policy_id: policyID });
} finally {
setBusy(false);
}
};
const authLabel = auth
? `${auth.user || auth.email || 'authenticated'} (${(auth.groups || []).join(', ') || 'no groups'})`
: authError || 'anonymous';
return (
<div className="app-shell">
<header className="topbar">
<div>
<h1>Soteria Backup Console</h1>
<p className="subtle">Dark-mode React UI for backup drills, policy control, and B2 consumption visibility.</p>
</div>
<div className="toolbar">
<span className={`chip ${auth ? 'good' : 'warn'}`}>{authLabel}</span>
<button type="button" className="secondary" onClick={() => void refreshAll()} disabled={busy}>
{busy ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</header>
<main className="layout">
<section className="panel scroll-panel">
<div className="panel-header">
<h2>PVC Inventory</h2>
<span className="subtle">{inventory?.generated_at ? `Updated ${formatTimestamp(inventory.generated_at)}` : 'No inventory yet'}</span>
</div>
{inventoryError && <p className="error">{inventoryError}</p>}
{!inventory && !inventoryError && <p className="subtle">Loading inventory...</p>}
{inventory?.namespaces.map((namespace) => (
<article key={namespace.name} className="namespace-block">
<div className="namespace-row">
<h3>{namespace.name}</h3>
<div className="actions">
<button type="button" className="secondary" onClick={() => void triggerNamespaceBackup(namespace.name)} disabled={busy}>
Backup namespace
</button>
<button type="button" className="secondary" onClick={() => openNamespaceSelection(namespace.name)}>
Restore namespace
</button>
</div>
</div>
<div className="pvc-grid">
{namespace.pvcs.map((pvc) => (
<article key={`${pvc.namespace}/${pvc.pvc}`} className="pvc-card">
<div className="pvc-title-row">
<div>
<h4>{pvc.pvc}</h4>
<p className="subtle tiny">{pvc.volume || 'unknown volume'} | {pvc.storage_class || 'no class'} | {pvc.capacity || 'unknown size'}</p>
</div>
<span className={`chip ${pvc.healthy ? 'good' : 'bad'}`}>
{pvc.healthy ? 'Healthy' : pvc.health_reason || 'Needs attention'}
</span>
</div>
<p className="subtle tiny">
Last backup: {pvc.last_backup_at ? `${formatTimestamp(pvc.last_backup_at)} (${(pvc.last_backup_age_hours || 0).toFixed(1)}h ago)` : 'never'}
</p>
<p className="subtle tiny">
Backups: {pvc.completed_backups}/{pvc.backup_count} completed | Latest size: {formatBytes(pvc.last_backup_size_bytes)} | Total stored: {formatBytes(pvc.total_backup_size_bytes)}
</p>
{pvc.error && <p className="error tiny">{pvc.error}</p>}
<div className="actions">
<button type="button" onClick={() => void triggerBackup(pvc.namespace, pvc.pvc)} disabled={busy}>
Backup now
</button>
<button type="button" className="secondary" onClick={() => void openPVCSelection(pvc.namespace, pvc.pvc)}>
Restore
</button>
</div>
</article>
))}
</div>
</article>
))}
</section>
<section className="column">
<section className="panel">
<h2>Restore Planner</h2>
<p className="subtle tiny">Inventory restore buttons preload this panel. This is where restore dry-runs and execution happen.</p>
{selection.kind === 'none' && <p className="subtle">Choose Restore on a PVC or namespace to begin.</p>}
{selection.kind === 'pvc' && (
<div className="stack">
<p className="subtle tiny"><strong>Source:</strong> {selection.namespace}/{selection.pvc} ({selection.volume})</p>
<label>
Backup snapshot
<select value={restoreBackupURL} onChange={(event) => setRestoreBackupURL(event.target.value)}>
{selection.backups.length === 0 && <option value="">No completed backups</option>}
{selection.backups.map((item) => (
<option key={item.url || item.name} value={item.url || ''}>
{item.name} | {item.created || 'unknown time'} | {item.size || 'size n/a'}
</option>
))}
</select>
</label>
<label>
Target namespace
<select value={restoreNamespace} onChange={(event) => setRestoreNamespace(event.target.value)}>
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label>
Target PVC
<input value={restorePVC} onChange={(event) => setRestorePVC(event.target.value)} />
</label>
<div className="actions">
<button type="button" onClick={() => void runPVCRestore(false)} disabled={busy || !restoreBackupURL}>
Create restore PVC
</button>
<button type="button" className="secondary" onClick={() => void runPVCRestore(true)} disabled={busy || !restoreBackupURL}>
Dry run
</button>
</div>
</div>
)}
{selection.kind === 'namespace' && (
<div className="stack">
<p className="subtle tiny"><strong>Source namespace:</strong> {selection.namespace}</p>
<label>
Target namespace
<select value={namespaceRestoreTarget} onChange={(event) => setNamespaceRestoreTarget(event.target.value)}>
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label>
Target PVC prefix
<input value={namespaceRestorePrefix} onChange={(event) => setNamespaceRestorePrefix(event.target.value)} />
</label>
<label>
Snapshot hint (optional)
<input value={namespaceRestoreSnapshot} onChange={(event) => setNamespaceRestoreSnapshot(event.target.value)} placeholder="blank = latest completed" />
</label>
<div className="actions">
<button type="button" onClick={() => void runNamespaceRestore(false)} disabled={busy}>
Create restore PVCs
</button>
<button type="button" className="secondary" onClick={() => void runNamespaceRestore(true)} disabled={busy}>
Dry run
</button>
</div>
</div>
)}
</section>
<section className="panel action-panel">
<h2>Last Action</h2>
<pre>{lastAction}</pre>
</section>
</section>
<section className="column">
<section className="panel">
<div className="panel-header">
<h2>B2 Consumption</h2>
<button type="button" className="secondary" onClick={() => void loadB2Usage()} disabled={busy}>Refresh B2</button>
</div>
{b2Error && <p className="error">{b2Error}</p>}
{!b2Error && !b2Usage.enabled && <p className="subtle">B2 monitoring is disabled in Soteria config.</p>}
{!b2Error && b2Usage.enabled && !b2Usage.available && <p className="error">{b2Usage.error || 'B2 usage currently unavailable.'}</p>}
{b2Usage.enabled && (
<div className="stack">
<p className="subtle tiny">Endpoint: {b2Usage.endpoint || 'n/a'} | Region: {b2Usage.region || 'n/a'}</p>
<p className="subtle tiny">Last scan: {formatTimestamp(b2Usage.scanned_at)} | Duration: {b2Usage.scan_duration_ms || 0}ms</p>
<div className="stat-grid">
<div className="stat">
<span className="label">Stored bytes</span>
<strong>{formatBytes(b2Usage.total_bytes)}</strong>
</div>
<div className="stat">
<span className="label">Objects</span>
<strong>{b2Usage.total_objects}</strong>
</div>
<div className="stat">
<span className="label">Recent bytes (24h)</span>
<strong>{formatBytes(b2Usage.recent_bytes_24h)}</strong>
</div>
<div className="stat">
<span className="label">Recent objects (24h)</span>
<strong>{b2Usage.recent_objects_24h}</strong>
</div>
</div>
<p className="subtle tiny">Recent 24h values are object-change bandwidth proxy from bucket scans. B2 egress billing totals are not exposed by S3 object listing.</p>
<div className="bucket-table-wrap">
<table>
<thead>
<tr>
<th>Bucket</th>
<th>Objects</th>
<th>Stored</th>
<th>Recent 24h</th>
</tr>
</thead>
<tbody>
{(b2Usage.buckets || []).map((bucket) => (
<tr key={bucket.name}>
<td>
<div>{bucket.name}</div>
<div className="subtle tiny">Last object: {formatTimestamp(bucket.last_modified_at)}</div>
</td>
<td>{bucket.object_count}</td>
<td>{formatBytes(bucket.total_bytes)}</td>
<td>{formatBytes(bucket.recent_bytes_24h)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</section>
<section className="panel scroll-panel">
<h2>Backup Policies</h2>
<div className="stack">
<label>
Namespace
<select value={policyNamespace} onChange={(event) => setPolicyNamespace(event.target.value)}>
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label>
PVC (optional)
<input value={policyPVC} onChange={(event) => setPolicyPVC(event.target.value)} placeholder="blank means all PVCs in namespace" />
</label>
<label>
Interval hours
<input
type="number"
min={1}
value={policyIntervalHours}
onChange={(event) => setPolicyIntervalHours(Math.max(1, Number(event.target.value || 1)))}
/>
</label>
<label className="checkbox-row">
<input type="checkbox" checked={policyEnabled} onChange={(event) => setPolicyEnabled(event.target.checked)} />
Enabled
</label>
<button type="button" onClick={() => void savePolicy()} disabled={busy || !policyNamespace}>Save policy</button>
</div>
{policyError && <p className="error">{policyError}</p>}
{!policyError && policies.length === 0 && <p className="subtle">No policies yet.</p>}
<div className="policy-list">
{policies.map((policy) => (
<article key={policy.id} className="policy-item">
<div className="policy-head">
<strong>{policy.namespace}/{policy.pvc || '*'}</strong>
<span className={`chip ${policy.enabled ? 'good' : 'bad'}`}>{policy.enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<p className="subtle tiny">Every {policy.interval_hours}h | Updated {formatTimestamp(policy.updated_at || policy.created_at)}</p>
<div className="actions">
<button
type="button"
className="secondary"
onClick={() => {
setPolicyNamespace(policy.namespace);
setPolicyPVC(policy.pvc || '');
setPolicyIntervalHours(policy.interval_hours);
setPolicyEnabled(policy.enabled);
}}
>
Load
</button>
<button type="button" className="secondary" onClick={() => void deletePolicy(policy.id)} disabled={busy}>
Delete
</button>
</div>
</article>
))}
</div>
</section>
</section>
</main>
</div>
);
}
export default App;

9
web/src/main.tsx Normal file
View File

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

374
web/src/styles.css Normal file
View File

@ -0,0 +1,374 @@
:root {
color-scheme: dark;
--bg: #090d14;
--bg-alt: #101726;
--card: #131e31;
--card-alt: #17263b;
--line: #23324d;
--text: #e8efff;
--muted: #9eb2d8;
--accent: #3ea7ff;
--accent-soft: #203759;
--good: #48c88e;
--bad: #ff6a78;
--warn: #f1b45a;
--shadow: rgba(4, 8, 15, 0.4);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'IBM Plex Sans', 'Segoe UI', sans-serif;
color: var(--text);
background:
radial-gradient(1200px 500px at 20% -20%, #1f3656 0%, transparent 60%),
radial-gradient(1000px 700px at 120% 10%, #1f2f4e 0%, transparent 50%),
var(--bg);
}
h1,
h2,
h3,
h4,
p {
margin: 0;
}
button,
input,
select {
font: inherit;
}
.app-shell {
min-height: 100vh;
padding: 20px;
}
.topbar {
max-width: 1780px;
margin: 0 auto 18px;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(150deg, rgba(19, 30, 49, 0.96), rgba(12, 20, 33, 0.96));
box-shadow: 0 16px 34px var(--shadow);
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.topbar h1 {
font-size: clamp(1.35rem, 1.7vw, 1.9rem);
letter-spacing: 0.02em;
}
.subtle {
color: var(--muted);
}
.tiny {
font-size: 0.84rem;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.layout {
max-width: 1780px;
margin: 0 auto;
display: grid;
grid-template-columns: minmax(430px, 1.5fr) minmax(360px, 1fr) minmax(380px, 1.05fr);
gap: 16px;
align-items: start;
}
.column {
display: grid;
gap: 16px;
}
.panel {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(150deg, rgba(19, 30, 49, 0.95), rgba(16, 23, 38, 0.96));
box-shadow: 0 12px 30px var(--shadow);
padding: 14px;
display: grid;
gap: 12px;
}
.scroll-panel {
max-height: calc(100vh - 160px);
overflow: auto;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.chip {
border-radius: 999px;
border: 1px solid var(--line);
padding: 4px 10px;
font-size: 0.82rem;
background: var(--bg-alt);
color: var(--muted);
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.chip.good {
border-color: rgba(72, 200, 142, 0.6);
color: var(--good);
background: rgba(72, 200, 142, 0.11);
}
.chip.bad {
border-color: rgba(255, 106, 120, 0.6);
color: var(--bad);
background: rgba(255, 106, 120, 0.12);
}
.chip.warn {
border-color: rgba(241, 180, 90, 0.6);
color: var(--warn);
background: rgba(241, 180, 90, 0.12);
}
button {
border: 1px solid transparent;
border-radius: 10px;
padding: 7px 12px;
cursor: pointer;
color: #091220;
background: var(--accent);
font-weight: 600;
}
button.secondary {
border-color: var(--line);
color: var(--text);
background: var(--accent-soft);
}
button:disabled {
opacity: 0.65;
cursor: wait;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.error {
color: var(--bad);
font-weight: 600;
}
.namespace-block {
border: 1px solid rgba(35, 50, 77, 0.65);
border-radius: 14px;
padding: 10px;
display: grid;
gap: 10px;
background: rgba(12, 18, 30, 0.65);
}
.namespace-row {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.namespace-row h3 {
font-size: 1.03rem;
}
.pvc-grid {
display: grid;
gap: 9px;
}
.pvc-card {
border: 1px solid rgba(35, 50, 77, 0.8);
border-radius: 12px;
background: linear-gradient(155deg, rgba(23, 38, 59, 0.92), rgba(15, 25, 41, 0.92));
padding: 10px;
display: grid;
gap: 8px;
}
.pvc-title-row {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
}
.pvc-title-row h4 {
font-size: 0.98rem;
}
label {
display: grid;
gap: 6px;
font-size: 0.9rem;
color: var(--muted);
}
input,
select {
border: 1px solid var(--line);
border-radius: 10px;
color: var(--text);
background: rgba(9, 13, 20, 0.8);
padding: 8px 10px;
}
.stack {
display: grid;
gap: 10px;
}
pre {
margin: 0;
border-radius: 12px;
border: 1px solid var(--line);
background: rgba(6, 10, 16, 0.98);
color: #dbf0ff;
padding: 10px;
max-height: 290px;
overflow: auto;
font-size: 0.82rem;
}
.action-panel {
min-height: 280px;
}
.stat-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat {
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(13, 24, 39, 0.86);
padding: 8px;
display: grid;
gap: 4px;
}
.stat .label {
color: var(--muted);
font-size: 0.78rem;
}
.bucket-table-wrap {
overflow: auto;
max-height: 270px;
border: 1px solid var(--line);
border-radius: 10px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th,
td {
text-align: left;
padding: 8px;
border-bottom: 1px solid rgba(35, 50, 77, 0.6);
vertical-align: top;
}
th {
position: sticky;
top: 0;
background: rgba(10, 16, 28, 0.96);
}
.policy-list {
display: grid;
gap: 8px;
}
.policy-item {
border: 1px solid rgba(35, 50, 77, 0.8);
border-radius: 11px;
background: rgba(11, 18, 31, 0.78);
padding: 9px;
display: grid;
gap: 7px;
}
.policy-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
}
@media (max-width: 1420px) {
.layout {
grid-template-columns: minmax(420px, 1.5fr) minmax(330px, 1fr);
}
.layout > .column:last-child {
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 1080px) {
.app-shell {
padding: 12px;
}
.layout {
grid-template-columns: 1fr;
}
.layout > .column:last-child {
grid-template-columns: 1fr;
}
.scroll-panel {
max-height: none;
}
.stat-grid {
grid-template-columns: 1fr;
}
}

20
web/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

10
web/vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
emptyOutDir: true
}
});