ui: migrate soteria console to react and add b2 telemetry
This commit is contained in:
parent
a5aa9e6a5f
commit
42fa848a82
2
.gitignore
vendored
2
.gitignore
vendored
@ -4,3 +4,5 @@
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
/web/node_modules/
|
||||||
|
/web/dist/
|
||||||
|
|||||||
@ -1,4 +1,12 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# 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
|
FROM --platform=$BUILDPLATFORM golang:1.25.0 AS builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@ -6,6 +14,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=ui-builder /src/web/dist ./internal/server/ui-dist
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|||||||
72
README.md
72
README.md
@ -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.
|
- Namespace-wide backup and restore batch execution.
|
||||||
- Restore into a new target PVC with conflict checks and best-effort cleanup on failure.
|
- 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.
|
- 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.
|
- A built-in React + TypeScript UI (dark-mode default) suitable for publishing behind an authenticated ingress.
|
||||||
- Prometheus-format backup freshness telemetry for Grafana rollups.
|
- 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.
|
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`
|
- `GET /v1/policies`
|
||||||
- `POST /v1/policies`
|
- `POST /v1/policies`
|
||||||
- `DELETE /v1/policies/<policy-id>`
|
- `DELETE /v1/policies/<policy-id>`
|
||||||
|
- `GET /v1/b2`
|
||||||
|
|
||||||
## API examples
|
## API examples
|
||||||
|
|
||||||
@ -163,6 +164,37 @@ POST /v1/policies
|
|||||||
- Leave `pvc` empty to target all PVCs in that namespace.
|
- Leave `pvc` empty to target all PVCs in that namespace.
|
||||||
- Policies are stored in secret `SOTERIA_POLICY_SECRET_NAME` under key `policies.json`.
|
- 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
|
## Authentication and authorization
|
||||||
|
|
||||||
When `SOTERIA_AUTH_REQUIRED=true`, Soteria expects trusted auth headers from a fronting proxy such as `oauth2-proxy`:
|
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`
|
- `soteria_inventory_refresh_timestamp_seconds`
|
||||||
- `pvc_backup_age_hours{namespace,pvc,volume,driver}`
|
- `pvc_backup_age_hours{namespace,pvc,volume,driver}`
|
||||||
- `pvc_backup_health{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_last_success_timestamp_seconds{namespace,pvc,volume,driver}`
|
||||||
- `pvc_backup_count{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`.
|
`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_METRICS_REFRESH_SECONDS` default `300`
|
||||||
- `SOTERIA_POLICY_EVAL_SECONDS` default `300`
|
- `SOTERIA_POLICY_EVAL_SECONDS` default `300`
|
||||||
- `SOTERIA_POLICY_SECRET_NAME` default `soteria-policies`
|
- `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
|
## 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.
|
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.
|
A template is in `deploy/secret-example.yaml`. Do not commit real credentials.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
@ -253,6 +320,7 @@ The example Service is annotated for Prometheus scraping of `/metrics`.
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Longhorn inventory and metrics are based on discovered backup records per PVC.
|
- 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.
|
- Scheduled policy execution currently applies to Longhorn driver.
|
||||||
- Restic backup and restore execution exists, but inventory-style telemetry is currently Longhorn-focused.
|
- 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.
|
- For Atlas production, place Soteria behind an authenticated ingress and trust only proxy-injected auth headers.
|
||||||
|
|||||||
@ -13,3 +13,6 @@ data:
|
|||||||
SOTERIA_ALLOWED_GROUPS: "admin,maintenance"
|
SOTERIA_ALLOWED_GROUPS: "admin,maintenance"
|
||||||
SOTERIA_BACKUP_MAX_AGE_HOURS: "24"
|
SOTERIA_BACKUP_MAX_AGE_HOURS: "24"
|
||||||
SOTERIA_METRICS_REFRESH_SECONDS: "300"
|
SOTERIA_METRICS_REFRESH_SECONDS: "300"
|
||||||
|
SOTERIA_B2_ENABLED: "false"
|
||||||
|
SOTERIA_B2_SCAN_INTERVAL_SECONDS: "900"
|
||||||
|
SOTERIA_B2_SCAN_TIMEOUT_SECONDS: "120"
|
||||||
|
|||||||
20
go.mod
20
go.mod
@ -3,6 +3,7 @@ module scm.bstein.dev/bstein/soteria
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/minio/minio-go/v7 v7.0.100
|
||||||
k8s.io/api v0.35.0
|
k8s.io/api v0.35.0
|
||||||
k8s.io/apimachinery v0.35.0
|
k8s.io/apimachinery v0.35.0
|
||||||
k8s.io/client-go v0.35.0
|
k8s.io/client-go v0.35.0
|
||||||
@ -10,8 +11,10 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
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/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // 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-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.20.2 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // 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/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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // 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/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/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // 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/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/term v0.37.0 // indirect
|
golang.org/x/term v0.38.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.9.0 // indirect
|
golang.org/x/time v0.9.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.8 // indirect
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
|
|||||||
53
go.sum
53
go.sum
@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
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 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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=
|
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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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/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 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
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-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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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/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 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
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 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
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/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
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 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
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 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
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.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
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 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@ -64,6 +64,9 @@ type PVCInventory struct {
|
|||||||
LastBackupAt string `json:"last_backup_at,omitempty"`
|
LastBackupAt string `json:"last_backup_at,omitempty"`
|
||||||
LastBackupAgeHours float64 `json:"last_backup_age_hours,omitempty"`
|
LastBackupAgeHours float64 `json:"last_backup_age_hours,omitempty"`
|
||||||
BackupCount int `json:"backup_count"`
|
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"`
|
Healthy bool `json:"healthy"`
|
||||||
HealthReason string `json:"health_reason,omitempty"`
|
HealthReason string `json:"health_reason,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
@ -169,3 +172,27 @@ type NamespaceRestoreResponse struct {
|
|||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
Results []NamespaceRestoreResult `json:"results"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@ -21,6 +21,8 @@ const (
|
|||||||
defaultPolicyEval = 300 * time.Second
|
defaultPolicyEval = 300 * time.Second
|
||||||
defaultBackupMaxAge = 24 * time.Hour
|
defaultBackupMaxAge = 24 * time.Hour
|
||||||
defaultPolicySecret = "soteria-policies"
|
defaultPolicySecret = "soteria-policies"
|
||||||
|
defaultB2ScanInterval = 15 * time.Minute
|
||||||
|
defaultB2ScanTimeout = 2 * time.Minute
|
||||||
serviceNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
|
serviceNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -48,6 +50,19 @@ type Config struct {
|
|||||||
PolicyEvalInterval time.Duration
|
PolicyEvalInterval time.Duration
|
||||||
PolicySecretName string
|
PolicySecretName string
|
||||||
BackupMaxAge time.Duration
|
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) {
|
func Load() (*Config, error) {
|
||||||
@ -87,6 +102,19 @@ func Load() (*Config, error) {
|
|||||||
cfg.PolicyEvalInterval = defaultPolicyEval
|
cfg.PolicyEvalInterval = defaultPolicyEval
|
||||||
cfg.BackupMaxAge = defaultBackupMaxAge
|
cfg.BackupMaxAge = defaultBackupMaxAge
|
||||||
cfg.PolicySecretName = getenvDefault("SOTERIA_POLICY_SECRET_NAME", defaultPolicySecret)
|
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 {
|
if ttl, ok := getenvInt("SOTERIA_JOB_TTL_SECONDS"); ok {
|
||||||
cfg.JobTTLSeconds = int32(ttl)
|
cfg.JobTTLSeconds = int32(ttl)
|
||||||
@ -103,6 +131,18 @@ func Load() (*Config, error) {
|
|||||||
if hours, ok := getenvFloat("SOTERIA_BACKUP_MAX_AGE_HOURS"); ok {
|
if hours, ok := getenvFloat("SOTERIA_BACKUP_MAX_AGE_HOURS"); ok {
|
||||||
cfg.BackupMaxAge = time.Duration(hours * float64(time.Hour))
|
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.ResticRepository == "" {
|
||||||
if cfg.BackupDriver == "restic" {
|
if cfg.BackupDriver == "restic" {
|
||||||
@ -148,6 +188,23 @@ func Load() (*Config, error) {
|
|||||||
if cfg.BackupMaxAge <= 0 {
|
if cfg.BackupMaxAge <= 0 {
|
||||||
return nil, errors.New("SOTERIA_BACKUP_MAX_AGE_HOURS must be greater than zero")
|
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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
278
internal/server/b2.go
Normal file
278
internal/server/b2.go
Normal 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
|
||||||
|
}
|
||||||
@ -28,8 +28,24 @@ type telemetry struct {
|
|||||||
inventoryRefreshTime float64
|
inventoryRefreshTime float64
|
||||||
pvcBackupAgeHours map[string]metricSample
|
pvcBackupAgeHours map[string]metricSample
|
||||||
pvcBackupHealth map[string]metricSample
|
pvcBackupHealth map[string]metricSample
|
||||||
|
pvcBackupHealthReason map[string]metricSample
|
||||||
pvcBackupLastSuccess map[string]metricSample
|
pvcBackupLastSuccess map[string]metricSample
|
||||||
pvcBackupCount 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 {
|
func newTelemetry() *telemetry {
|
||||||
@ -42,8 +58,17 @@ func newTelemetry() *telemetry {
|
|||||||
authzDenials: map[string]metricSample{},
|
authzDenials: map[string]metricSample{},
|
||||||
pvcBackupAgeHours: map[string]metricSample{},
|
pvcBackupAgeHours: map[string]metricSample{},
|
||||||
pvcBackupHealth: map[string]metricSample{},
|
pvcBackupHealth: map[string]metricSample{},
|
||||||
|
pvcBackupHealthReason: map[string]metricSample{},
|
||||||
pvcBackupLastSuccess: map[string]metricSample{},
|
pvcBackupLastSuccess: map[string]metricSample{},
|
||||||
pvcBackupCount: 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.pvcBackupAgeHours = map[string]metricSample{}
|
||||||
t.pvcBackupHealth = map[string]metricSample{}
|
t.pvcBackupHealth = map[string]metricSample{}
|
||||||
|
t.pvcBackupHealthReason = map[string]metricSample{}
|
||||||
t.pvcBackupLastSuccess = map[string]metricSample{}
|
t.pvcBackupLastSuccess = map[string]metricSample{}
|
||||||
t.pvcBackupCount = 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 _, namespace := range inv.Namespaces {
|
||||||
for _, pvc := range namespace.PVCs {
|
for _, pvc := range namespace.PVCs {
|
||||||
@ -114,6 +143,16 @@ func (t *telemetry) RecordInventory(inv api.InventoryResponse) {
|
|||||||
"driver": pvc.Driver,
|
"driver": pvc.Driver,
|
||||||
}
|
}
|
||||||
setMetric(t.pvcBackupCount, labels, float64(pvc.BackupCount))
|
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 {
|
if pvc.Healthy {
|
||||||
setMetric(t.pvcBackupHealth, labels, 1)
|
setMetric(t.pvcBackupHealth, labels, 1)
|
||||||
} else {
|
} else {
|
||||||
@ -132,6 +171,50 @@ func (t *telemetry) RecordInventory(inv api.InventoryResponse) {
|
|||||||
t.inventoryRefreshTime = float64(time.Now().Unix())
|
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 {
|
func (t *telemetry) render() string {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
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, "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_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", "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_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_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()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -19,6 +20,7 @@ import (
|
|||||||
"scm.bstein.dev/bstein/soteria/internal/longhorn"
|
"scm.bstein.dev/bstein/soteria/internal/longhorn"
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
|
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -48,10 +50,13 @@ type Server struct {
|
|||||||
longhorn longhornClient
|
longhorn longhornClient
|
||||||
metrics *telemetry
|
metrics *telemetry
|
||||||
handler http.Handler
|
handler http.Handler
|
||||||
|
ui *uiRenderer
|
||||||
policyMu sync.RWMutex
|
policyMu sync.RWMutex
|
||||||
policies map[string]api.BackupPolicy
|
policies map[string]api.BackupPolicy
|
||||||
runMu sync.Mutex
|
runMu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
|
b2Mu sync.RWMutex
|
||||||
|
b2Usage api.B2UsageResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
type authIdentity struct {
|
type authIdentity struct {
|
||||||
@ -77,6 +82,7 @@ func New(cfg *config.Config, client *k8s.Client, lh *longhorn.Client) *Server {
|
|||||||
client: client,
|
client: client,
|
||||||
longhorn: lh,
|
longhorn: lh,
|
||||||
metrics: newTelemetry(),
|
metrics: newTelemetry(),
|
||||||
|
ui: newUIRenderer(),
|
||||||
policies: map[string]api.BackupPolicy{},
|
policies: map[string]api.BackupPolicy{},
|
||||||
}
|
}
|
||||||
s.handler = http.HandlerFunc(s.route)
|
s.handler = http.HandlerFunc(s.route)
|
||||||
@ -89,13 +95,23 @@ func (s *Server) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.refreshTelemetry(ctx)
|
s.refreshTelemetry(ctx)
|
||||||
|
s.refreshB2Usage(ctx)
|
||||||
s.runPolicyCycle(ctx)
|
s.runPolicyCycle(ctx)
|
||||||
|
|
||||||
metricsTicker := time.NewTicker(s.cfg.MetricsRefreshInterval)
|
metricsTicker := time.NewTicker(s.cfg.MetricsRefreshInterval)
|
||||||
policyTicker := time.NewTicker(s.cfg.PolicyEvalInterval)
|
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() {
|
go func() {
|
||||||
defer metricsTicker.Stop()
|
defer metricsTicker.Stop()
|
||||||
defer policyTicker.Stop()
|
defer policyTicker.Stop()
|
||||||
|
if b2Ticker != nil {
|
||||||
|
defer b2Ticker.Stop()
|
||||||
|
}
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@ -104,6 +120,8 @@ func (s *Server) Start(ctx context.Context) {
|
|||||||
s.refreshTelemetry(ctx)
|
s.refreshTelemetry(ctx)
|
||||||
case <-policyTicker.C:
|
case <-policyTicker.C:
|
||||||
s.runPolicyCycle(ctx)
|
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 {
|
switch r.URL.Path {
|
||||||
case "/":
|
case "/":
|
||||||
s.handleUI(w, r)
|
s.handleUI(w, r)
|
||||||
|
case "/v1/b2":
|
||||||
|
s.handleB2Usage(w, r)
|
||||||
case "/v1/whoami":
|
case "/v1/whoami":
|
||||||
s.handleWhoAmI(w, r)
|
s.handleWhoAmI(w, r)
|
||||||
case "/v1/inventory":
|
case "/v1/inventory":
|
||||||
@ -155,10 +175,19 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "/v1/policies":
|
case "/v1/policies":
|
||||||
s.handlePolicies(w, r)
|
s.handlePolicies(w, r)
|
||||||
default:
|
default:
|
||||||
|
if s.ui != nil && s.ui.ServeAsset(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if strings.HasPrefix(r.URL.Path, "/v1/policies/") {
|
if strings.HasPrefix(r.URL.Path, "/v1/policies/") {
|
||||||
s.handlePolicyByID(w, r)
|
s.handlePolicyByID(w, r)
|
||||||
return
|
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")
|
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")
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
if s.ui == nil {
|
||||||
_, _ = w.Write([]byte(uiHTML))
|
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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
entry.BackupCount = len(backups)
|
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)
|
latest, latestTime, ok := latestCompletedBackup(backups)
|
||||||
if !ok {
|
if !ok {
|
||||||
entry.Healthy = false
|
entry.Healthy = false
|
||||||
@ -849,6 +893,7 @@ func (s *Server) enrichPVCInventory(ctx context.Context, entry *api.PVCInventory
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
entry.LastBackupAt = latest.Created
|
entry.LastBackupAt = latest.Created
|
||||||
|
entry.LastBackupSizeBytes = float64(parseSizeBytes(latest.Size))
|
||||||
if latestTime.IsZero() {
|
if latestTime.IsZero() {
|
||||||
entry.Healthy = false
|
entry.Healthy = false
|
||||||
entry.HealthReason = "unknown_timestamp"
|
entry.HealthReason = "unknown_timestamp"
|
||||||
@ -1434,3 +1479,23 @@ func sanitizeName(value string) string {
|
|||||||
func roundHours(value float64) float64 {
|
func roundHours(value float64) float64 {
|
||||||
return math.Round(value*100) / 100
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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) {
|
func TestPoliciesCRUD(t *testing.T) {
|
||||||
srv := &Server{
|
srv := &Server{
|
||||||
cfg: &config.Config{AuthRequired: false, BackupDriver: "longhorn", Namespace: "maintenance", PolicySecretName: "soteria-policies"},
|
cfg: &config.Config{AuthRequired: false, BackupDriver: "longhorn", Namespace: "maintenance", PolicySecretName: "soteria-policies"},
|
||||||
|
|||||||
40
internal/server/ui-dist/assets/index-Bl8gBoZ6.js
Normal file
40
internal/server/ui-dist/assets/index-Bl8gBoZ6.js
Normal file
File diff suppressed because one or more lines are too long
1
internal/server/ui-dist/assets/index-Dq7_oHb5.css
Normal file
1
internal/server/ui-dist/assets/index-Dq7_oHb5.css
Normal 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}}
|
||||||
13
internal/server/ui-dist/index.html
Normal file
13
internal/server/ui-dist/index.html
Normal 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>
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import _ "embed"
|
|
||||||
|
|
||||||
//go:embed ui.html
|
|
||||||
var uiHTML string
|
|
||||||
@ -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('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll("'", ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
69
internal/server/ui_renderer.go
Normal file
69
internal/server/ui_renderer.go
Normal 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
12
web/index.html
Normal 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
1729
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/package.json
Normal file
22
web/package.json
Normal 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
732
web/src/App.tsx
Normal 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
9
web/src/main.tsx
Normal 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
374
web/src/styles.css
Normal 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
20
web/tsconfig.json
Normal 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
10
web/vite.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user