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
|
||||
.env
|
||||
.DS_Store
|
||||
/web/node_modules/
|
||||
/web/dist/
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:22-alpine AS ui-builder
|
||||
|
||||
WORKDIR /src/web
|
||||
COPY web/package*.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.0 AS builder
|
||||
|
||||
WORKDIR /src
|
||||
@ -6,6 +14,7 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=ui-builder /src/web/dist ./internal/server/ui-dist
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
|
||||
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.
|
||||
- Restore into a new target PVC with conflict checks and best-effort cleanup on failure.
|
||||
- Policy-based scheduled backups (per PVC or all PVCs in a namespace), persisted in-cluster.
|
||||
- A simple built-in UI suitable for publishing behind an authenticated ingress.
|
||||
- Prometheus-format backup freshness telemetry for Grafana rollups.
|
||||
- A built-in React + TypeScript UI (dark-mode default) suitable for publishing behind an authenticated ingress.
|
||||
- Prometheus-format backup freshness and B2 consumption telemetry for Grafana rollups.
|
||||
|
||||
For Longhorn, backups are crash-consistent at the volume level and delegated to the Longhorn control plane.
|
||||
|
||||
@ -34,6 +34,7 @@ Protected endpoints when `SOTERIA_AUTH_REQUIRED=true`:
|
||||
- `GET /v1/policies`
|
||||
- `POST /v1/policies`
|
||||
- `DELETE /v1/policies/<policy-id>`
|
||||
- `GET /v1/b2`
|
||||
|
||||
## API examples
|
||||
|
||||
@ -163,6 +164,37 @@ POST /v1/policies
|
||||
- Leave `pvc` empty to target all PVCs in that namespace.
|
||||
- Policies are stored in secret `SOTERIA_POLICY_SECRET_NAME` under key `policies.json`.
|
||||
|
||||
### GET /v1/b2
|
||||
|
||||
Returns B2 account/bucket consumption based on S3-compatible object scans.
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"available": true,
|
||||
"endpoint": "https://s3.us-west-004.backblazeb2.com",
|
||||
"region": "us-west-004",
|
||||
"scanned_at": "2026-04-12T16:00:00Z",
|
||||
"scan_duration_ms": 824,
|
||||
"total_objects": 1324,
|
||||
"total_bytes": 18407542931,
|
||||
"recent_objects_24h": 18,
|
||||
"recent_bytes_24h": 12245812,
|
||||
"buckets": [
|
||||
{
|
||||
"name": "atlas-backups",
|
||||
"object_count": 1240,
|
||||
"total_bytes": 18288473811,
|
||||
"recent_objects_24h": 12,
|
||||
"recent_bytes_24h": 8542198,
|
||||
"last_modified_at": "2026-04-12T15:43:19Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Recent 24h values are an object-change proxy and do not represent full B2 billing egress totals.
|
||||
|
||||
## Authentication and authorization
|
||||
|
||||
When `SOTERIA_AUTH_REQUIRED=true`, Soteria expects trusted auth headers from a fronting proxy such as `oauth2-proxy`:
|
||||
@ -194,8 +226,24 @@ Implemented metrics:
|
||||
- `soteria_inventory_refresh_timestamp_seconds`
|
||||
- `pvc_backup_age_hours{namespace,pvc,volume,driver}`
|
||||
- `pvc_backup_health{namespace,pvc,volume,driver}`
|
||||
- `pvc_backup_health_reason{namespace,pvc,volume,driver,reason}`
|
||||
- `pvc_backup_last_success_timestamp_seconds{namespace,pvc,volume,driver}`
|
||||
- `pvc_backup_count{namespace,pvc,volume,driver}`
|
||||
- `pvc_backup_completed_count{namespace,pvc,volume,driver}`
|
||||
- `pvc_backup_last_size_bytes{namespace,pvc,volume,driver}`
|
||||
- `pvc_backup_total_size_bytes{namespace,pvc,volume,driver}`
|
||||
- `soteria_b2_scan_success`
|
||||
- `soteria_b2_scan_timestamp_seconds`
|
||||
- `soteria_b2_scan_duration_seconds`
|
||||
- `soteria_b2_account_objects`
|
||||
- `soteria_b2_account_bytes`
|
||||
- `soteria_b2_account_recent_objects_24h`
|
||||
- `soteria_b2_account_recent_bytes_24h`
|
||||
- `soteria_b2_bucket_objects{bucket}`
|
||||
- `soteria_b2_bucket_bytes{bucket}`
|
||||
- `soteria_b2_bucket_recent_objects_24h{bucket}`
|
||||
- `soteria_b2_bucket_recent_bytes_24h{bucket}`
|
||||
- `soteria_b2_bucket_last_modified_timestamp_seconds{bucket}`
|
||||
|
||||
`pvc_backup_health` is `1` when the most recent successful backup is within `SOTERIA_BACKUP_MAX_AGE_HOURS`, otherwise `0`.
|
||||
|
||||
@ -225,6 +273,19 @@ Environment variables:
|
||||
- `SOTERIA_METRICS_REFRESH_SECONDS` default `300`
|
||||
- `SOTERIA_POLICY_EVAL_SECONDS` default `300`
|
||||
- `SOTERIA_POLICY_SECRET_NAME` default `soteria-policies`
|
||||
- `SOTERIA_B2_ENABLED` default `false` (auto-enabled if endpoint/secret are set)
|
||||
- `SOTERIA_B2_ENDPOINT` optional S3-compatible endpoint (for B2, usually `https://s3.<region>.backblazeb2.com`)
|
||||
- `SOTERIA_B2_REGION` optional region override (auto-inferred for Backblaze endpoint patterns)
|
||||
- `SOTERIA_B2_BUCKETS` optional comma-separated bucket allowlist (defaults to scanning all accessible buckets)
|
||||
- `SOTERIA_B2_ACCESS_KEY_ID` optional static key (can come from secret instead)
|
||||
- `SOTERIA_B2_SECRET_ACCESS_KEY` optional static secret key (can come from secret instead)
|
||||
- `SOTERIA_B2_SECRET_NAMESPACE` optional secret namespace (defaults to service namespace when secret name is set)
|
||||
- `SOTERIA_B2_SECRET_NAME` optional secret containing B2 keys
|
||||
- `SOTERIA_B2_ACCESS_KEY_FIELD` default `AWS_ACCESS_KEY_ID`
|
||||
- `SOTERIA_B2_SECRET_KEY_FIELD` default `AWS_SECRET_ACCESS_KEY`
|
||||
- `SOTERIA_B2_ENDPOINT_FIELD` default `AWS_ENDPOINTS`
|
||||
- `SOTERIA_B2_SCAN_INTERVAL_SECONDS` default `900`
|
||||
- `SOTERIA_B2_SCAN_TIMEOUT_SECONDS` default `120`
|
||||
|
||||
## Secrets
|
||||
|
||||
@ -236,6 +297,12 @@ Create a secret named `soteria-restic` in the Soteria namespace, or set `SOTERIA
|
||||
|
||||
The service copies this secret into the target namespace per job and attaches an owner reference so it is cleaned up with the Job.
|
||||
|
||||
For B2 scanning, you can point Soteria at a secret via `SOTERIA_B2_SECRET_NAME`. Expected keys by default:
|
||||
|
||||
- `AWS_ACCESS_KEY_ID`
|
||||
- `AWS_SECRET_ACCESS_KEY`
|
||||
- `AWS_ENDPOINTS` (optional if `SOTERIA_B2_ENDPOINT` is set)
|
||||
|
||||
A template is in `deploy/secret-example.yaml`. Do not commit real credentials.
|
||||
|
||||
## Deployment
|
||||
@ -253,6 +320,7 @@ The example Service is annotated for Prometheus scraping of `/metrics`.
|
||||
## Notes
|
||||
|
||||
- Longhorn inventory and metrics are based on discovered backup records per PVC.
|
||||
- Inventory `Restore` buttons load source context into the restore planner; restore execution happens from the planner panel.
|
||||
- Scheduled policy execution currently applies to Longhorn driver.
|
||||
- Restic backup and restore execution exists, but inventory-style telemetry is currently Longhorn-focused.
|
||||
- For Atlas production, place Soteria behind an authenticated ingress and trust only proxy-injected auth headers.
|
||||
|
||||
@ -13,3 +13,6 @@ data:
|
||||
SOTERIA_ALLOWED_GROUPS: "admin,maintenance"
|
||||
SOTERIA_BACKUP_MAX_AGE_HOURS: "24"
|
||||
SOTERIA_METRICS_REFRESH_SECONDS: "300"
|
||||
SOTERIA_B2_ENABLED: "false"
|
||||
SOTERIA_B2_SCAN_INTERVAL_SECONDS: "900"
|
||||
SOTERIA_B2_SCAN_TIMEOUT_SECONDS: "120"
|
||||
|
||||
20
go.mod
20
go.mod
@ -3,6 +3,7 @@ module scm.bstein.dev/bstein/soteria
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/minio/minio-go/v7 v7.0.100
|
||||
k8s.io/api v0.35.0
|
||||
k8s.io/apimachinery v0.35.0
|
||||
k8s.io/client-go v0.35.0
|
||||
@ -10,8 +11,10 @@ require (
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
@ -20,19 +23,28 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
|
||||
53
go.sum
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
@ -33,6 +37,13 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@ -42,6 +53,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
|
||||
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@ -54,10 +71,14 @@ github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@ -71,30 +92,34 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@ -64,6 +64,9 @@ type PVCInventory struct {
|
||||
LastBackupAt string `json:"last_backup_at,omitempty"`
|
||||
LastBackupAgeHours float64 `json:"last_backup_age_hours,omitempty"`
|
||||
BackupCount int `json:"backup_count"`
|
||||
CompletedBackups int `json:"completed_backups"`
|
||||
LastBackupSizeBytes float64 `json:"last_backup_size_bytes,omitempty"`
|
||||
TotalBackupSizeBytes float64 `json:"total_backup_size_bytes,omitempty"`
|
||||
Healthy bool `json:"healthy"`
|
||||
HealthReason string `json:"health_reason,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
@ -169,3 +172,27 @@ type NamespaceRestoreResponse struct {
|
||||
Failed int `json:"failed"`
|
||||
Results []NamespaceRestoreResult `json:"results"`
|
||||
}
|
||||
|
||||
type B2UsageResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Available bool `json:"available"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
ScannedAt string `json:"scanned_at,omitempty"`
|
||||
ScanDurationMS int64 `json:"scan_duration_ms,omitempty"`
|
||||
TotalObjects int64 `json:"total_objects"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
RecentObjects24h int64 `json:"recent_objects_24h"`
|
||||
RecentBytes24h int64 `json:"recent_bytes_24h"`
|
||||
Buckets []B2BucketUsage `json:"buckets,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type B2BucketUsage struct {
|
||||
Name string `json:"name"`
|
||||
ObjectCount int64 `json:"object_count"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
RecentObjects24h int64 `json:"recent_objects_24h"`
|
||||
RecentBytes24h int64 `json:"recent_bytes_24h"`
|
||||
LastModifiedAt string `json:"last_modified_at,omitempty"`
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ const (
|
||||
defaultPolicyEval = 300 * time.Second
|
||||
defaultBackupMaxAge = 24 * time.Hour
|
||||
defaultPolicySecret = "soteria-policies"
|
||||
defaultB2ScanInterval = 15 * time.Minute
|
||||
defaultB2ScanTimeout = 2 * time.Minute
|
||||
serviceNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
|
||||
)
|
||||
|
||||
@ -48,6 +50,19 @@ type Config struct {
|
||||
PolicyEvalInterval time.Duration
|
||||
PolicySecretName string
|
||||
BackupMaxAge time.Duration
|
||||
B2Enabled bool
|
||||
B2Endpoint string
|
||||
B2Region string
|
||||
B2Buckets []string
|
||||
B2AccessKeyID string
|
||||
B2SecretAccessKey string
|
||||
B2SecretNamespace string
|
||||
B2SecretName string
|
||||
B2AccessKeyField string
|
||||
B2SecretKeyField string
|
||||
B2EndpointField string
|
||||
B2ScanInterval time.Duration
|
||||
B2ScanTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@ -87,6 +102,19 @@ func Load() (*Config, error) {
|
||||
cfg.PolicyEvalInterval = defaultPolicyEval
|
||||
cfg.BackupMaxAge = defaultBackupMaxAge
|
||||
cfg.PolicySecretName = getenvDefault("SOTERIA_POLICY_SECRET_NAME", defaultPolicySecret)
|
||||
cfg.B2Enabled = getenvBool("SOTERIA_B2_ENABLED")
|
||||
cfg.B2Endpoint = getenv("SOTERIA_B2_ENDPOINT")
|
||||
cfg.B2Region = getenv("SOTERIA_B2_REGION")
|
||||
cfg.B2Buckets = parseCSV(getenv("SOTERIA_B2_BUCKETS"))
|
||||
cfg.B2AccessKeyID = getenv("SOTERIA_B2_ACCESS_KEY_ID")
|
||||
cfg.B2SecretAccessKey = getenv("SOTERIA_B2_SECRET_ACCESS_KEY")
|
||||
cfg.B2SecretNamespace = getenv("SOTERIA_B2_SECRET_NAMESPACE")
|
||||
cfg.B2SecretName = getenv("SOTERIA_B2_SECRET_NAME")
|
||||
cfg.B2AccessKeyField = getenvDefault("SOTERIA_B2_ACCESS_KEY_FIELD", "AWS_ACCESS_KEY_ID")
|
||||
cfg.B2SecretKeyField = getenvDefault("SOTERIA_B2_SECRET_KEY_FIELD", "AWS_SECRET_ACCESS_KEY")
|
||||
cfg.B2EndpointField = getenvDefault("SOTERIA_B2_ENDPOINT_FIELD", "AWS_ENDPOINTS")
|
||||
cfg.B2ScanInterval = defaultB2ScanInterval
|
||||
cfg.B2ScanTimeout = defaultB2ScanTimeout
|
||||
|
||||
if ttl, ok := getenvInt("SOTERIA_JOB_TTL_SECONDS"); ok {
|
||||
cfg.JobTTLSeconds = int32(ttl)
|
||||
@ -103,6 +131,18 @@ func Load() (*Config, error) {
|
||||
if hours, ok := getenvFloat("SOTERIA_BACKUP_MAX_AGE_HOURS"); ok {
|
||||
cfg.BackupMaxAge = time.Duration(hours * float64(time.Hour))
|
||||
}
|
||||
if seconds, ok := getenvInt("SOTERIA_B2_SCAN_INTERVAL_SECONDS"); ok {
|
||||
cfg.B2ScanInterval = time.Duration(seconds) * time.Second
|
||||
}
|
||||
if seconds, ok := getenvInt("SOTERIA_B2_SCAN_TIMEOUT_SECONDS"); ok {
|
||||
cfg.B2ScanTimeout = time.Duration(seconds) * time.Second
|
||||
}
|
||||
if !cfg.B2Enabled && (cfg.B2Endpoint != "" || cfg.B2SecretName != "") {
|
||||
cfg.B2Enabled = true
|
||||
}
|
||||
if cfg.B2SecretName != "" && cfg.B2SecretNamespace == "" {
|
||||
cfg.B2SecretNamespace = cfg.Namespace
|
||||
}
|
||||
|
||||
if cfg.ResticRepository == "" {
|
||||
if cfg.BackupDriver == "restic" {
|
||||
@ -148,6 +188,23 @@ func Load() (*Config, error) {
|
||||
if cfg.BackupMaxAge <= 0 {
|
||||
return nil, errors.New("SOTERIA_BACKUP_MAX_AGE_HOURS must be greater than zero")
|
||||
}
|
||||
if cfg.B2Enabled {
|
||||
if cfg.B2Endpoint == "" && cfg.B2SecretName == "" {
|
||||
return nil, errors.New("SOTERIA_B2_ENDPOINT or SOTERIA_B2_SECRET_NAME is required when B2 monitoring is enabled")
|
||||
}
|
||||
if strings.TrimSpace(cfg.B2AccessKeyField) == "" {
|
||||
return nil, errors.New("SOTERIA_B2_ACCESS_KEY_FIELD must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(cfg.B2SecretKeyField) == "" {
|
||||
return nil, errors.New("SOTERIA_B2_SECRET_KEY_FIELD must not be empty")
|
||||
}
|
||||
if cfg.B2ScanInterval <= 0 {
|
||||
return nil, errors.New("SOTERIA_B2_SCAN_INTERVAL_SECONDS must be greater than zero")
|
||||
}
|
||||
if cfg.B2ScanTimeout <= 0 {
|
||||
return nil, errors.New("SOTERIA_B2_SCAN_TIMEOUT_SECONDS must be greater than zero")
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
278
internal/server/b2.go
Normal file
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
|
||||
pvcBackupAgeHours map[string]metricSample
|
||||
pvcBackupHealth map[string]metricSample
|
||||
pvcBackupHealthReason map[string]metricSample
|
||||
pvcBackupLastSuccess map[string]metricSample
|
||||
pvcBackupCount map[string]metricSample
|
||||
pvcBackupCompletedCount map[string]metricSample
|
||||
pvcBackupLastSizeBytes map[string]metricSample
|
||||
pvcBackupTotalSizeBytes map[string]metricSample
|
||||
b2BucketObjects map[string]metricSample
|
||||
b2BucketBytes map[string]metricSample
|
||||
b2BucketRecentObjects map[string]metricSample
|
||||
b2BucketRecentBytes map[string]metricSample
|
||||
b2BucketLastModified map[string]metricSample
|
||||
b2ScanSuccess float64
|
||||
b2ScanTimestamp float64
|
||||
b2ScanDurationSeconds float64
|
||||
b2AccountObjects float64
|
||||
b2AccountBytes float64
|
||||
b2AccountRecentObjects float64
|
||||
b2AccountRecentBytes float64
|
||||
}
|
||||
|
||||
func newTelemetry() *telemetry {
|
||||
@ -42,8 +58,17 @@ func newTelemetry() *telemetry {
|
||||
authzDenials: map[string]metricSample{},
|
||||
pvcBackupAgeHours: map[string]metricSample{},
|
||||
pvcBackupHealth: map[string]metricSample{},
|
||||
pvcBackupHealthReason: map[string]metricSample{},
|
||||
pvcBackupLastSuccess: map[string]metricSample{},
|
||||
pvcBackupCount: map[string]metricSample{},
|
||||
pvcBackupCompletedCount: map[string]metricSample{},
|
||||
pvcBackupLastSizeBytes: map[string]metricSample{},
|
||||
pvcBackupTotalSizeBytes: map[string]metricSample{},
|
||||
b2BucketObjects: map[string]metricSample{},
|
||||
b2BucketBytes: map[string]metricSample{},
|
||||
b2BucketRecentObjects: map[string]metricSample{},
|
||||
b2BucketRecentBytes: map[string]metricSample{},
|
||||
b2BucketLastModified: map[string]metricSample{},
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,8 +127,12 @@ func (t *telemetry) RecordInventory(inv api.InventoryResponse) {
|
||||
|
||||
t.pvcBackupAgeHours = map[string]metricSample{}
|
||||
t.pvcBackupHealth = map[string]metricSample{}
|
||||
t.pvcBackupHealthReason = map[string]metricSample{}
|
||||
t.pvcBackupLastSuccess = map[string]metricSample{}
|
||||
t.pvcBackupCount = map[string]metricSample{}
|
||||
t.pvcBackupCompletedCount = map[string]metricSample{}
|
||||
t.pvcBackupLastSizeBytes = map[string]metricSample{}
|
||||
t.pvcBackupTotalSizeBytes = map[string]metricSample{}
|
||||
|
||||
for _, namespace := range inv.Namespaces {
|
||||
for _, pvc := range namespace.PVCs {
|
||||
@ -114,6 +143,16 @@ func (t *telemetry) RecordInventory(inv api.InventoryResponse) {
|
||||
"driver": pvc.Driver,
|
||||
}
|
||||
setMetric(t.pvcBackupCount, labels, float64(pvc.BackupCount))
|
||||
setMetric(t.pvcBackupCompletedCount, labels, float64(pvc.CompletedBackups))
|
||||
setMetric(t.pvcBackupLastSizeBytes, labels, pvc.LastBackupSizeBytes)
|
||||
setMetric(t.pvcBackupTotalSizeBytes, labels, pvc.TotalBackupSizeBytes)
|
||||
reasonLabels := cloneLabels(labels)
|
||||
reason := strings.TrimSpace(pvc.HealthReason)
|
||||
if reason == "" {
|
||||
reason = "unknown"
|
||||
}
|
||||
reasonLabels["reason"] = reason
|
||||
setMetric(t.pvcBackupHealthReason, reasonLabels, 1)
|
||||
if pvc.Healthy {
|
||||
setMetric(t.pvcBackupHealth, labels, 1)
|
||||
} else {
|
||||
@ -132,6 +171,50 @@ func (t *telemetry) RecordInventory(inv api.InventoryResponse) {
|
||||
t.inventoryRefreshTime = float64(time.Now().Unix())
|
||||
}
|
||||
|
||||
func (t *telemetry) RecordB2Usage(usage api.B2UsageResponse) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
t.b2BucketObjects = map[string]metricSample{}
|
||||
t.b2BucketBytes = map[string]metricSample{}
|
||||
t.b2BucketRecentObjects = map[string]metricSample{}
|
||||
t.b2BucketRecentBytes = map[string]metricSample{}
|
||||
t.b2BucketLastModified = map[string]metricSample{}
|
||||
t.b2ScanSuccess = 0
|
||||
t.b2ScanDurationSeconds = float64(usage.ScanDurationMS) / 1000.0
|
||||
t.b2AccountObjects = float64(usage.TotalObjects)
|
||||
t.b2AccountBytes = float64(usage.TotalBytes)
|
||||
t.b2AccountRecentObjects = float64(usage.RecentObjects24h)
|
||||
t.b2AccountRecentBytes = float64(usage.RecentBytes24h)
|
||||
|
||||
if usage.Available {
|
||||
t.b2ScanSuccess = 1
|
||||
}
|
||||
if usage.ScannedAt != "" {
|
||||
if ts, err := time.Parse(time.RFC3339, usage.ScannedAt); err == nil {
|
||||
t.b2ScanTimestamp = float64(ts.Unix())
|
||||
} else {
|
||||
t.b2ScanTimestamp = float64(time.Now().Unix())
|
||||
}
|
||||
} else {
|
||||
t.b2ScanTimestamp = float64(time.Now().Unix())
|
||||
}
|
||||
|
||||
for _, bucket := range usage.Buckets {
|
||||
labels := map[string]string{"bucket": bucket.Name}
|
||||
setMetric(t.b2BucketObjects, labels, float64(bucket.ObjectCount))
|
||||
setMetric(t.b2BucketBytes, labels, float64(bucket.TotalBytes))
|
||||
setMetric(t.b2BucketRecentObjects, labels, float64(bucket.RecentObjects24h))
|
||||
setMetric(t.b2BucketRecentBytes, labels, float64(bucket.RecentBytes24h))
|
||||
if bucket.LastModifiedAt == "" {
|
||||
continue
|
||||
}
|
||||
if ts, err := time.Parse(time.RFC3339, bucket.LastModifiedAt); err == nil {
|
||||
setMetric(t.b2BucketLastModified, labels, float64(ts.Unix()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *telemetry) render() string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
@ -147,8 +230,24 @@ func (t *telemetry) render() string {
|
||||
writeMetricFamily(&b, "soteria_inventory_refresh_timestamp_seconds", "gauge", "Unix timestamp of the last successful inventory refresh.", []metricSample{{value: t.inventoryRefreshTime}})
|
||||
writeMetricFamily(&b, "pvc_backup_age_hours", "gauge", "Age in hours of the latest successful PVC backup known to Soteria.", metricValues(t.pvcBackupAgeHours))
|
||||
writeMetricFamily(&b, "pvc_backup_health", "gauge", "PVC backup health according to Soteria: 1=fresh backup within policy, 0=missing/stale/error.", metricValues(t.pvcBackupHealth))
|
||||
writeMetricFamily(&b, "pvc_backup_health_reason", "gauge", "PVC backup health reason marker with reason label set to 1.", metricValues(t.pvcBackupHealthReason))
|
||||
writeMetricFamily(&b, "pvc_backup_last_success_timestamp_seconds", "gauge", "Unix timestamp of the latest successful PVC backup known to Soteria.", metricValues(t.pvcBackupLastSuccess))
|
||||
writeMetricFamily(&b, "pvc_backup_count", "gauge", "Count of backup records discovered for a PVC.", metricValues(t.pvcBackupCount))
|
||||
writeMetricFamily(&b, "pvc_backup_completed_count", "gauge", "Count of completed backup records discovered for a PVC.", metricValues(t.pvcBackupCompletedCount))
|
||||
writeMetricFamily(&b, "pvc_backup_last_size_bytes", "gauge", "Size in bytes of the latest completed backup for a PVC.", metricValues(t.pvcBackupLastSizeBytes))
|
||||
writeMetricFamily(&b, "pvc_backup_total_size_bytes", "gauge", "Total bytes across discovered backup records for a PVC.", metricValues(t.pvcBackupTotalSizeBytes))
|
||||
writeMetricFamily(&b, "soteria_b2_scan_success", "gauge", "Whether the latest B2 consumption scan succeeded (1) or failed (0).", []metricSample{{value: t.b2ScanSuccess}})
|
||||
writeMetricFamily(&b, "soteria_b2_scan_timestamp_seconds", "gauge", "Unix timestamp of the latest B2 consumption scan attempt.", []metricSample{{value: t.b2ScanTimestamp}})
|
||||
writeMetricFamily(&b, "soteria_b2_scan_duration_seconds", "gauge", "Duration in seconds of the latest B2 consumption scan attempt.", []metricSample{{value: t.b2ScanDurationSeconds}})
|
||||
writeMetricFamily(&b, "soteria_b2_account_objects", "gauge", "Total object count discovered across scanned B2 buckets.", []metricSample{{value: t.b2AccountObjects}})
|
||||
writeMetricFamily(&b, "soteria_b2_account_bytes", "gauge", "Total stored bytes discovered across scanned B2 buckets.", []metricSample{{value: t.b2AccountBytes}})
|
||||
writeMetricFamily(&b, "soteria_b2_account_recent_objects_24h", "gauge", "Object count with LastModified in the last 24h across scanned B2 buckets.", []metricSample{{value: t.b2AccountRecentObjects}})
|
||||
writeMetricFamily(&b, "soteria_b2_account_recent_bytes_24h", "gauge", "Bytes with LastModified in the last 24h across scanned B2 buckets.", []metricSample{{value: t.b2AccountRecentBytes}})
|
||||
writeMetricFamily(&b, "soteria_b2_bucket_objects", "gauge", "Object count discovered per scanned B2 bucket.", metricValues(t.b2BucketObjects))
|
||||
writeMetricFamily(&b, "soteria_b2_bucket_bytes", "gauge", "Stored bytes discovered per scanned B2 bucket.", metricValues(t.b2BucketBytes))
|
||||
writeMetricFamily(&b, "soteria_b2_bucket_recent_objects_24h", "gauge", "Object count with LastModified in the last 24h per scanned B2 bucket.", metricValues(t.b2BucketRecentObjects))
|
||||
writeMetricFamily(&b, "soteria_b2_bucket_recent_bytes_24h", "gauge", "Bytes with LastModified in the last 24h per scanned B2 bucket.", metricValues(t.b2BucketRecentBytes))
|
||||
writeMetricFamily(&b, "soteria_b2_bucket_last_modified_timestamp_seconds", "gauge", "Unix timestamp of the most recent object observed in each scanned B2 bucket.", metricValues(t.b2BucketLastModified))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@ -19,6 +20,7 @@ import (
|
||||
"scm.bstein.dev/bstein/soteria/internal/longhorn"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
k8svalidation "k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
@ -48,10 +50,13 @@ type Server struct {
|
||||
longhorn longhornClient
|
||||
metrics *telemetry
|
||||
handler http.Handler
|
||||
ui *uiRenderer
|
||||
policyMu sync.RWMutex
|
||||
policies map[string]api.BackupPolicy
|
||||
runMu sync.Mutex
|
||||
running bool
|
||||
b2Mu sync.RWMutex
|
||||
b2Usage api.B2UsageResponse
|
||||
}
|
||||
|
||||
type authIdentity struct {
|
||||
@ -77,6 +82,7 @@ func New(cfg *config.Config, client *k8s.Client, lh *longhorn.Client) *Server {
|
||||
client: client,
|
||||
longhorn: lh,
|
||||
metrics: newTelemetry(),
|
||||
ui: newUIRenderer(),
|
||||
policies: map[string]api.BackupPolicy{},
|
||||
}
|
||||
s.handler = http.HandlerFunc(s.route)
|
||||
@ -89,13 +95,23 @@ func (s *Server) Start(ctx context.Context) {
|
||||
}
|
||||
|
||||
s.refreshTelemetry(ctx)
|
||||
s.refreshB2Usage(ctx)
|
||||
s.runPolicyCycle(ctx)
|
||||
|
||||
metricsTicker := time.NewTicker(s.cfg.MetricsRefreshInterval)
|
||||
policyTicker := time.NewTicker(s.cfg.PolicyEvalInterval)
|
||||
var b2Ticker *time.Ticker
|
||||
var b2Tick <-chan time.Time
|
||||
if s.cfg.B2Enabled {
|
||||
b2Ticker = time.NewTicker(s.cfg.B2ScanInterval)
|
||||
b2Tick = b2Ticker.C
|
||||
}
|
||||
go func() {
|
||||
defer metricsTicker.Stop()
|
||||
defer policyTicker.Stop()
|
||||
if b2Ticker != nil {
|
||||
defer b2Ticker.Stop()
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@ -104,6 +120,8 @@ func (s *Server) Start(ctx context.Context) {
|
||||
s.refreshTelemetry(ctx)
|
||||
case <-policyTicker.C:
|
||||
s.runPolicyCycle(ctx)
|
||||
case <-b2Tick:
|
||||
s.refreshB2Usage(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@ -138,6 +156,8 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
s.handleUI(w, r)
|
||||
case "/v1/b2":
|
||||
s.handleB2Usage(w, r)
|
||||
case "/v1/whoami":
|
||||
s.handleWhoAmI(w, r)
|
||||
case "/v1/inventory":
|
||||
@ -155,10 +175,19 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
|
||||
case "/v1/policies":
|
||||
s.handlePolicies(w, r)
|
||||
default:
|
||||
if s.ui != nil && s.ui.ServeAsset(w, r) {
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/v1/policies/") {
|
||||
s.handlePolicyByID(w, r)
|
||||
return
|
||||
}
|
||||
// Serve SPA index for deep links (for example /backup) while preserving
|
||||
// explicit API and asset 404 behavior.
|
||||
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/v1/") && !strings.Contains(r.URL.Path, ".") {
|
||||
s.handleUI(w, r)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
}
|
||||
}
|
||||
@ -176,8 +205,13 @@ func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(uiHTML))
|
||||
if s.ui == nil {
|
||||
writeError(w, http.StatusInternalServerError, "UI renderer is unavailable")
|
||||
return
|
||||
}
|
||||
if err := s.ui.ServeIndex(w, r); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleWhoAmI(w http.ResponseWriter, r *http.Request) {
|
||||
@ -838,6 +872,16 @@ func (s *Server) enrichPVCInventory(ctx context.Context, entry *api.PVCInventory
|
||||
return
|
||||
}
|
||||
entry.BackupCount = len(backups)
|
||||
totalBackupSize := int64(0)
|
||||
completedBackups := 0
|
||||
for _, backup := range backups {
|
||||
totalBackupSize += parseSizeBytes(backup.Size)
|
||||
if strings.EqualFold(backup.State, "Completed") {
|
||||
completedBackups++
|
||||
}
|
||||
}
|
||||
entry.CompletedBackups = completedBackups
|
||||
entry.TotalBackupSizeBytes = float64(totalBackupSize)
|
||||
latest, latestTime, ok := latestCompletedBackup(backups)
|
||||
if !ok {
|
||||
entry.Healthy = false
|
||||
@ -849,6 +893,7 @@ func (s *Server) enrichPVCInventory(ctx context.Context, entry *api.PVCInventory
|
||||
return
|
||||
}
|
||||
entry.LastBackupAt = latest.Created
|
||||
entry.LastBackupSizeBytes = float64(parseSizeBytes(latest.Size))
|
||||
if latestTime.IsZero() {
|
||||
entry.Healthy = false
|
||||
entry.HealthReason = "unknown_timestamp"
|
||||
@ -1434,3 +1479,23 @@ func sanitizeName(value string) string {
|
||||
func roundHours(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
|
||||
func parseSizeBytes(raw string) int64 {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return value
|
||||
}
|
||||
if value, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(value)
|
||||
}
|
||||
if quantity, err := resource.ParseQuantity(raw); err == nil {
|
||||
return quantity.Value()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -243,6 +243,47 @@ func TestMetricsStayPublic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIFallbackHandlesDeepLinks(t *testing.T) {
|
||||
srv := &Server{
|
||||
cfg: &config.Config{AuthRequired: false},
|
||||
client: &fakeKubeClient{},
|
||||
longhorn: &fakeLonghornClient{},
|
||||
metrics: newTelemetry(),
|
||||
ui: newUIRenderer(),
|
||||
}
|
||||
srv.handler = http.HandlerFunc(srv.route)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/backup", nil)
|
||||
res := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
if !strings.Contains(res.Body.String(), `<div id="root"></div>`) {
|
||||
t.Fatalf("expected UI index body, got %q", res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIFallbackDoesNotMaskMissingAssets(t *testing.T) {
|
||||
srv := &Server{
|
||||
cfg: &config.Config{AuthRequired: false},
|
||||
client: &fakeKubeClient{},
|
||||
longhorn: &fakeLonghornClient{},
|
||||
metrics: newTelemetry(),
|
||||
ui: newUIRenderer(),
|
||||
}
|
||||
srv.handler = http.HandlerFunc(srv.route)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/assets/missing.js", nil)
|
||||
res := httptest.NewRecorder()
|
||||
srv.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", res.Code, res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoliciesCRUD(t *testing.T) {
|
||||
srv := &Server{
|
||||
cfg: &config.Config{AuthRequired: false, BackupDriver: "longhorn", Namespace: "maintenance", PolicySecretName: "soteria-policies"},
|
||||
|
||||
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