diff --git a/.gitignore b/.gitignore index 60e2dd1..e0fdc5a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ *.log .env .DS_Store +/web/node_modules/ +/web/dist/ diff --git a/Dockerfile b/Dockerfile index d60f34e..585ce57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 6ef2bd2..6c1613f 100644 --- a/README.md +++ b/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/` +- `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..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. diff --git a/deploy/configmap.yaml b/deploy/configmap.yaml index 019445a..4be4c1c 100644 --- a/deploy/configmap.yaml +++ b/deploy/configmap.yaml @@ -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" diff --git a/go.mod b/go.mod index 90e8f6e..163442e 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index b5f9dbe..e646c2e 100644 --- a/go.sum +++ b/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= diff --git a/internal/api/types.go b/internal/api/types.go index 92ead47..48b4467 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -53,20 +53,23 @@ type NamespaceInventory struct { } type PVCInventory struct { - Namespace string `json:"namespace"` - PVC string `json:"pvc"` - Volume string `json:"volume,omitempty"` - Phase string `json:"phase,omitempty"` - StorageClass string `json:"storage_class,omitempty"` - Capacity string `json:"capacity,omitempty"` - AccessModes []string `json:"access_modes,omitempty"` - Driver string `json:"driver,omitempty"` - LastBackupAt string `json:"last_backup_at,omitempty"` - LastBackupAgeHours float64 `json:"last_backup_age_hours,omitempty"` - BackupCount int `json:"backup_count"` - Healthy bool `json:"healthy"` - HealthReason string `json:"health_reason,omitempty"` - Error string `json:"error,omitempty"` + Namespace string `json:"namespace"` + PVC string `json:"pvc"` + Volume string `json:"volume,omitempty"` + Phase string `json:"phase,omitempty"` + StorageClass string `json:"storage_class,omitempty"` + Capacity string `json:"capacity,omitempty"` + AccessModes []string `json:"access_modes,omitempty"` + Driver string `json:"driver,omitempty"` + LastBackupAt string `json:"last_backup_at,omitempty"` + LastBackupAgeHours float64 `json:"last_backup_age_hours,omitempty"` + BackupCount int `json:"backup_count"` + CompletedBackups int `json:"completed_backups"` + LastBackupSizeBytes float64 `json:"last_backup_size_bytes,omitempty"` + TotalBackupSizeBytes float64 `json:"total_backup_size_bytes,omitempty"` + Healthy bool `json:"healthy"` + HealthReason string `json:"health_reason,omitempty"` + Error string `json:"error,omitempty"` } type BackupListResponse struct { @@ -169,3 +172,27 @@ type NamespaceRestoreResponse struct { Failed int `json:"failed"` Results []NamespaceRestoreResult `json:"results"` } + +type B2UsageResponse struct { + Enabled bool `json:"enabled"` + Available bool `json:"available"` + Endpoint string `json:"endpoint,omitempty"` + Region string `json:"region,omitempty"` + ScannedAt string `json:"scanned_at,omitempty"` + ScanDurationMS int64 `json:"scan_duration_ms,omitempty"` + TotalObjects int64 `json:"total_objects"` + TotalBytes int64 `json:"total_bytes"` + RecentObjects24h int64 `json:"recent_objects_24h"` + RecentBytes24h int64 `json:"recent_bytes_24h"` + Buckets []B2BucketUsage `json:"buckets,omitempty"` + Error string `json:"error,omitempty"` +} + +type B2BucketUsage struct { + Name string `json:"name"` + ObjectCount int64 `json:"object_count"` + TotalBytes int64 `json:"total_bytes"` + RecentObjects24h int64 `json:"recent_objects_24h"` + RecentBytes24h int64 `json:"recent_bytes_24h"` + LastModifiedAt string `json:"last_modified_at,omitempty"` +} diff --git a/internal/config/config.go b/internal/config/config.go index 63137f8..64990ec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/server/b2.go b/internal/server/b2.go new file mode 100644 index 0000000..d6c245c --- /dev/null +++ b/internal/server/b2.go @@ -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 +} diff --git a/internal/server/metrics.go b/internal/server/metrics.go index 2c53f14..6c3c795 100644 --- a/internal/server/metrics.go +++ b/internal/server/metrics.go @@ -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() } diff --git a/internal/server/server.go b/internal/server/server.go index a13b952..6bd2664 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 3f60ff8..9de80d2 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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(), `
`) { + 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"}, diff --git a/internal/server/ui-dist/assets/index-Bl8gBoZ6.js b/internal/server/ui-dist/assets/index-Bl8gBoZ6.js new file mode 100644 index 0000000..f38dc40 --- /dev/null +++ b/internal/server/ui-dist/assets/index-Bl8gBoZ6.js @@ -0,0 +1,40 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const o of l)if(o.type==="childList")for(const u of o.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&r(u)}).observe(document,{childList:!0,subtree:!0});function n(l){const o={};return l.integrity&&(o.integrity=l.integrity),l.referrerPolicy&&(o.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?o.credentials="include":l.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function r(l){if(l.ep)return;l.ep=!0;const o=n(l);fetch(l.href,o)}})();function Nc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var us={exports:{}},pl={},is={exports:{}},M={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var ar=Symbol.for("react.element"),Pc=Symbol.for("react.portal"),jc=Symbol.for("react.fragment"),zc=Symbol.for("react.strict_mode"),Tc=Symbol.for("react.profiler"),Lc=Symbol.for("react.provider"),Rc=Symbol.for("react.context"),Oc=Symbol.for("react.forward_ref"),Mc=Symbol.for("react.suspense"),Dc=Symbol.for("react.memo"),Ic=Symbol.for("react.lazy"),Gu=Symbol.iterator;function Fc(e){return e===null||typeof e!="object"?null:(e=Gu&&e[Gu]||e["@@iterator"],typeof e=="function"?e:null)}var ss={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},as=Object.assign,cs={};function kn(e,t,n){this.props=e,this.context=t,this.refs=cs,this.updater=n||ss}kn.prototype.isReactComponent={};kn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};kn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function fs(){}fs.prototype=kn.prototype;function Jo(e,t,n){this.props=e,this.context=t,this.refs=cs,this.updater=n||ss}var qo=Jo.prototype=new fs;qo.constructor=Jo;as(qo,kn.prototype);qo.isPureReactComponent=!0;var Zu=Array.isArray,ds=Object.prototype.hasOwnProperty,bo={current:null},ps={key:!0,ref:!0,__self:!0,__source:!0};function ms(e,t,n){var r,l={},o=null,u=null;if(t!=null)for(r in t.ref!==void 0&&(u=t.ref),t.key!==void 0&&(o=""+t.key),t)ds.call(t,r)&&!ps.hasOwnProperty(r)&&(l[r]=t[r]);var i=arguments.length-2;if(i===1)l.children=n;else if(1>>1,G=C[A];if(0>>1;Al(Z,R))Oel(J,Z)?(C[A]=J,C[Oe]=R,A=Oe):(C[A]=Z,C[b]=R,A=b);else if(Oel(J,R))C[A]=J,C[Oe]=R,A=Oe;else break e}}return T}function l(C,T){var R=C.sortIndex-T.sortIndex;return R!==0?R:C.id-T.id}if(typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var u=Date,i=u.now();e.unstable_now=function(){return u.now()-i}}var s=[],c=[],y=1,v=null,m=3,k=!1,S=!1,x=!1,L=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function d(C){for(var T=n(c);T!==null;){if(T.callback===null)r(c);else if(T.startTime<=C)r(c),T.sortIndex=T.expirationTime,t(s,T);else break;T=n(c)}}function g(C){if(x=!1,d(C),!S)if(n(s)!==null)S=!0,Ht(E);else{var T=n(c);T!==null&&Wt(g,T.startTime-C)}}function E(C,T){S=!1,x&&(x=!1,f(j),j=-1),k=!0;var R=m;try{for(d(T),v=n(s);v!==null&&(!(v.expirationTime>T)||C&&!he());){var A=v.callback;if(typeof A=="function"){v.callback=null,m=v.priorityLevel;var G=A(v.expirationTime<=T);T=e.unstable_now(),typeof G=="function"?v.callback=G:v===n(s)&&r(s),d(T)}else r(s);v=n(s)}if(v!==null)var Pt=!0;else{var b=n(c);b!==null&&Wt(g,b.startTime-T),Pt=!1}return Pt}finally{v=null,m=R,k=!1}}var N=!1,P=null,j=-1,V=5,O=-1;function he(){return!(e.unstable_now()-OC||125A?(C.sortIndex=R,t(c,C),n(s)===null&&C===n(c)&&(x?(f(j),j=-1):x=!0,Wt(g,R-A))):(C.sortIndex=G,t(s,C),S||k||(S=!0,Ht(E))),C},e.unstable_shouldYield=he,e.unstable_wrapCallback=function(C){var T=m;return function(){var R=m;m=T;try{return C.apply(this,arguments)}finally{m=R}}}})(ws);gs.exports=ws;var Gc=gs.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Zc=F,Ce=Gc;function w(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),lo=Object.prototype.hasOwnProperty,Jc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,qu={},bu={};function qc(e){return lo.call(bu,e)?!0:lo.call(qu,e)?!1:Jc.test(e)?bu[e]=!0:(qu[e]=!0,!1)}function bc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function ef(e,t,n,r){if(t===null||typeof t>"u"||bc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function me(e,t,n,r,l,o,u){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=o,this.removeEmptyString=u}var ue={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ue[e]=new me(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ue[t]=new me(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ue[e]=new me(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ue[e]=new me(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ue[e]=new me(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ue[e]=new me(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ue[e]=new me(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ue[e]=new me(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ue[e]=new me(e,5,!1,e.toLowerCase(),null,!1,!1)});var tu=/[\-:]([a-z])/g;function nu(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(tu,nu);ue[t]=new me(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(tu,nu);ue[t]=new me(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(tu,nu);ue[t]=new me(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ue[e]=new me(e,1,!1,e.toLowerCase(),null,!1,!1)});ue.xlinkHref=new me("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ue[e]=new me(e,1,!1,e.toLowerCase(),null,!0,!0)});function ru(e,t,n,r){var l=ue.hasOwnProperty(t)?ue[t]:null;(l!==null?l.type!==0:r||!(2i||l[u]!==o[i]){var s=` +`+l[u].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=u&&0<=i);break}}}finally{Ol=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?On(e):""}function tf(e){switch(e.tag){case 5:return On(e.type);case 16:return On("Lazy");case 13:return On("Suspense");case 19:return On("SuspenseList");case 0:case 2:case 15:return e=Ml(e.type,!1),e;case 11:return e=Ml(e.type.render,!1),e;case 1:return e=Ml(e.type,!0),e;default:return""}}function so(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Gt:return"Fragment";case Xt:return"Portal";case oo:return"Profiler";case lu:return"StrictMode";case uo:return"Suspense";case io:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case xs:return(e.displayName||"Context")+".Consumer";case Ss:return(e._context.displayName||"Context")+".Provider";case ou:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case uu:return t=e.displayName||null,t!==null?t:so(e.type)||"Memo";case it:t=e._payload,e=e._init;try{return so(e(t))}catch{}}return null}function nf(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return so(t);case 8:return t===lu?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function St(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Cs(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function rf(e){var t=Cs(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,o=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(u){r=""+u,o.call(this,u)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function vr(e){e._valueTracker||(e._valueTracker=rf(e))}function _s(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=Cs(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Hr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ao(e,t){var n=t.checked;return K({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ti(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=St(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Ns(e,t){t=t.checked,t!=null&&ru(e,"checked",t,!1)}function co(e,t){Ns(e,t);var n=St(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?fo(e,t.type,n):t.hasOwnProperty("defaultValue")&&fo(e,t.type,St(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ni(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function fo(e,t,n){(t!=="number"||Hr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Mn=Array.isArray;function un(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=yr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Yn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Fn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},lf=["Webkit","ms","Moz","O"];Object.keys(Fn).forEach(function(e){lf.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Fn[t]=Fn[e]})});function Ts(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Fn.hasOwnProperty(e)&&Fn[e]?(""+t).trim():t+"px"}function Ls(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Ts(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var of=K({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function ho(e,t){if(t){if(of[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(w(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(w(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(w(61))}if(t.style!=null&&typeof t.style!="object")throw Error(w(62))}}function vo(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var yo=null;function iu(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var go=null,sn=null,an=null;function oi(e){if(e=dr(e)){if(typeof go!="function")throw Error(w(280));var t=e.stateNode;t&&(t=gl(t),go(e.stateNode,e.type,t))}}function Rs(e){sn?an?an.push(e):an=[e]:sn=e}function Os(){if(sn){var e=sn,t=an;if(an=sn=null,oi(e),t)for(e=0;e>>=0,e===0?32:31-(yf(e)/gf|0)|0}var gr=64,wr=4194304;function Dn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Yr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,u=n&268435455;if(u!==0){var i=u&~l;i!==0?r=Dn(i):(o&=u,o!==0&&(r=Dn(o)))}else u=n&~l,u!==0?r=Dn(u):o!==0&&(r=Dn(o));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,o=t&-t,l>=o||l===16&&(o&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function cr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-$e(t),e[t]=n}function xf(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Bn),mi=" ",hi=!1;function bs(e,t){switch(e){case"keyup":return Zf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function ea(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Zt=!1;function qf(e,t){switch(e){case"compositionend":return ea(t);case"keypress":return t.which!==32?null:(hi=!0,mi);case"textInput":return e=t.data,e===mi&&hi?null:e;default:return null}}function bf(e,t){if(Zt)return e==="compositionend"||!hu&&bs(e,t)?(e=Js(),Mr=du=ft=null,Zt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=wi(n)}}function la(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?la(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function oa(){for(var e=window,t=Hr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Hr(e.document)}return t}function vu(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function sd(e){var t=oa(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&la(n.ownerDocument.documentElement,n)){if(r!==null&&vu(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=ki(n,o);var u=ki(n,r);l&&u&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(t),e.extend(u.node,u.offset)):(t.setEnd(u.node,u.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Jt=null,Co=null,Vn=null,_o=!1;function Si(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;_o||Jt==null||Jt!==Hr(r)||(r=Jt,"selectionStart"in r&&vu(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Vn&&bn(Vn,r)||(Vn=r,r=Zr(Co,"onSelect"),0en||(e.current=Lo[en],Lo[en]=null,en--)}function U(e,t){en++,Lo[en]=e.current,e.current=t}var xt={},ce=Ct(xt),ge=Ct(!1),It=xt;function mn(e,t){var n=e.type.contextTypes;if(!n)return xt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in n)l[o]=t[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function we(e){return e=e.childContextTypes,e!=null}function qr(){$(ge),$(ce)}function ji(e,t,n){if(ce.current!==xt)throw Error(w(168));U(ce,t),U(ge,n)}function ma(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(w(108,nf(e)||"Unknown",l));return K({},n,r)}function br(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||xt,It=ce.current,U(ce,e),U(ge,ge.current),!0}function zi(e,t,n){var r=e.stateNode;if(!r)throw Error(w(169));n?(e=ma(e,t,It),r.__reactInternalMemoizedMergedChildContext=e,$(ge),$(ce),U(ce,e)):$(ge),U(ge,n)}var Ge=null,wl=!1,Xl=!1;function ha(e){Ge===null?Ge=[e]:Ge.push(e)}function kd(e){wl=!0,ha(e)}function _t(){if(!Xl&&Ge!==null){Xl=!0;var e=0,t=I;try{var n=Ge;for(I=1;e>=u,l-=u,Ze=1<<32-$e(t)+l|n<j?(V=P,P=null):V=P.sibling;var O=m(f,P,d[j],g);if(O===null){P===null&&(P=V);break}e&&P&&O.alternate===null&&t(f,P),a=o(O,a,j),N===null?E=O:N.sibling=O,N=O,P=V}if(j===d.length)return n(f,P),H&&zt(f,j),E;if(P===null){for(;jj?(V=P,P=null):V=P.sibling;var he=m(f,P,O.value,g);if(he===null){P===null&&(P=V);break}e&&P&&he.alternate===null&&t(f,P),a=o(he,a,j),N===null?E=he:N.sibling=he,N=he,P=V}if(O.done)return n(f,P),H&&zt(f,j),E;if(P===null){for(;!O.done;j++,O=d.next())O=v(f,O.value,g),O!==null&&(a=o(O,a,j),N===null?E=O:N.sibling=O,N=O);return H&&zt(f,j),E}for(P=r(f,P);!O.done;j++,O=d.next())O=k(P,f,j,O.value,g),O!==null&&(e&&O.alternate!==null&&P.delete(O.key===null?j:O.key),a=o(O,a,j),N===null?E=O:N.sibling=O,N=O);return e&&P.forEach(function(lt){return t(f,lt)}),H&&zt(f,j),E}function L(f,a,d,g){if(typeof d=="object"&&d!==null&&d.type===Gt&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case hr:e:{for(var E=d.key,N=a;N!==null;){if(N.key===E){if(E=d.type,E===Gt){if(N.tag===7){n(f,N.sibling),a=l(N,d.props.children),a.return=f,f=a;break e}}else if(N.elementType===E||typeof E=="object"&&E!==null&&E.$$typeof===it&&Ri(E)===N.type){n(f,N.sibling),a=l(N,d.props),a.ref=zn(f,N,d),a.return=f,f=a;break e}n(f,N);break}else t(f,N);N=N.sibling}d.type===Gt?(a=Dt(d.props.children,f.mode,g,d.key),a.return=f,f=a):(g=Ar(d.type,d.key,d.props,null,f.mode,g),g.ref=zn(f,a,d),g.return=f,f=g)}return u(f);case Xt:e:{for(N=d.key;a!==null;){if(a.key===N)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(f,a.sibling),a=l(a,d.children||[]),a.return=f,f=a;break e}else{n(f,a);break}else t(f,a);a=a.sibling}a=no(d,f.mode,g),a.return=f,f=a}return u(f);case it:return N=d._init,L(f,a,N(d._payload),g)}if(Mn(d))return S(f,a,d,g);if(Cn(d))return x(f,a,d,g);Nr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(f,a.sibling),a=l(a,d),a.return=f,f=a):(n(f,a),a=to(d,f.mode,g),a.return=f,f=a),u(f)):n(f,a)}return L}var vn=wa(!0),ka=wa(!1),nl=Ct(null),rl=null,rn=null,ku=null;function Su(){ku=rn=rl=null}function xu(e){var t=nl.current;$(nl),e._currentValue=t}function Mo(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function fn(e,t){rl=e,ku=rn=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(ye=!0),e.firstContext=null)}function Le(e){var t=e._currentValue;if(ku!==e)if(e={context:e,memoizedValue:t,next:null},rn===null){if(rl===null)throw Error(w(308));rn=e,rl.dependencies={lanes:0,firstContext:e}}else rn=rn.next=e;return t}var Rt=null;function Eu(e){Rt===null?Rt=[e]:Rt.push(e)}function Sa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Eu(t)):(n.next=l.next,l.next=n),t.interleaved=n,tt(e,r)}function tt(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var st=!1;function Cu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function xa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function qe(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function yt(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,D&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,tt(e,n)}return l=r.interleaved,l===null?(t.next=t,Eu(r)):(t.next=l.next,l.next=t),r.interleaved=t,tt(e,n)}function Ir(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,au(e,n)}}function Oi(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,o=null;if(n=n.firstBaseUpdate,n!==null){do{var u={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};o===null?l=o=u:o=o.next=u,n=n.next}while(n!==null);o===null?l=o=t:o=o.next=t}else l=o=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:o,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function ll(e,t,n,r){var l=e.updateQueue;st=!1;var o=l.firstBaseUpdate,u=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var s=i,c=s.next;s.next=null,u===null?o=c:u.next=c,u=s;var y=e.alternate;y!==null&&(y=y.updateQueue,i=y.lastBaseUpdate,i!==u&&(i===null?y.firstBaseUpdate=c:i.next=c,y.lastBaseUpdate=s))}if(o!==null){var v=l.baseState;u=0,y=c=s=null,i=o;do{var m=i.lane,k=i.eventTime;if((r&m)===m){y!==null&&(y=y.next={eventTime:k,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var S=e,x=i;switch(m=t,k=n,x.tag){case 1:if(S=x.payload,typeof S=="function"){v=S.call(k,v,m);break e}v=S;break e;case 3:S.flags=S.flags&-65537|128;case 0:if(S=x.payload,m=typeof S=="function"?S.call(k,v,m):S,m==null)break e;v=K({},v,m);break e;case 2:st=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,m=l.effects,m===null?l.effects=[i]:m.push(i))}else k={eventTime:k,lane:m,tag:i.tag,payload:i.payload,callback:i.callback,next:null},y===null?(c=y=k,s=v):y=y.next=k,u|=m;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;m=i,i=m.next,m.next=null,l.lastBaseUpdate=m,l.shared.pending=null}}while(!0);if(y===null&&(s=v),l.baseState=s,l.firstBaseUpdate=c,l.lastBaseUpdate=y,t=l.shared.interleaved,t!==null){l=t;do u|=l.lane,l=l.next;while(l!==t)}else o===null&&(l.shared.lanes=0);Bt|=u,e.lanes=u,e.memoizedState=v}}function Mi(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Zl.transition;Zl.transition={};try{e(!1),t()}finally{I=n,Zl.transition=r}}function Ba(){return Re().memoizedState}function Cd(e,t,n){var r=wt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},$a(e))Va(t,n);else if(n=Sa(e,t,n,r),n!==null){var l=de();Ve(n,e,r,l),Aa(n,t,r)}}function _d(e,t,n){var r=wt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if($a(e))Va(t,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=t.lastRenderedReducer,o!==null))try{var u=t.lastRenderedState,i=o(u,n);if(l.hasEagerState=!0,l.eagerState=i,Ae(i,u)){var s=t.interleaved;s===null?(l.next=l,Eu(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=Sa(e,t,l,r),n!==null&&(l=de(),Ve(n,e,r,l),Aa(n,t,r))}}function $a(e){var t=e.alternate;return e===Q||t!==null&&t===Q}function Va(e,t){An=ul=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Aa(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,au(e,n)}}var il={readContext:Le,useCallback:ie,useContext:ie,useEffect:ie,useImperativeHandle:ie,useInsertionEffect:ie,useLayoutEffect:ie,useMemo:ie,useReducer:ie,useRef:ie,useState:ie,useDebugValue:ie,useDeferredValue:ie,useTransition:ie,useMutableSource:ie,useSyncExternalStore:ie,useId:ie,unstable_isNewReconciler:!1},Nd={readContext:Le,useCallback:function(e,t){return We().memoizedState=[e,t===void 0?null:t],e},useContext:Le,useEffect:Ii,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Ur(4194308,4,Ma.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ur(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ur(4,2,e,t)},useMemo:function(e,t){var n=We();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=We();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=Cd.bind(null,Q,e),[r.memoizedState,e]},useRef:function(e){var t=We();return e={current:e},t.memoizedState=e},useState:Di,useDebugValue:Ru,useDeferredValue:function(e){return We().memoizedState=e},useTransition:function(){var e=Di(!1),t=e[0];return e=Ed.bind(null,e[1]),We().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=Q,l=We();if(H){if(n===void 0)throw Error(w(407));n=n()}else{if(n=t(),re===null)throw Error(w(349));Ut&30||Na(r,t,n)}l.memoizedState=n;var o={value:n,getSnapshot:t};return l.queue=o,Ii(ja.bind(null,r,o,e),[e]),r.flags|=2048,ir(9,Pa.bind(null,r,o,n,t),void 0,null),n},useId:function(){var e=We(),t=re.identifierPrefix;if(H){var n=Je,r=Ze;n=(r&~(1<<32-$e(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=or++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(n,{is:r.is}):(e=u.createElement(n),n==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,n),e[Qe]=t,e[nr]=r,qa(e,t,!1,!1),t.stateNode=e;e:{switch(u=vo(n,r),n){case"dialog":B("cancel",e),B("close",e),l=r;break;case"iframe":case"object":case"embed":B("load",e),l=r;break;case"video":case"audio":for(l=0;lwn&&(t.flags|=128,r=!0,Tn(o,!1),t.lanes=4194304)}else{if(!r)if(e=ol(u),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),Tn(o,!0),o.tail===null&&o.tailMode==="hidden"&&!u.alternate&&!H)return se(t),null}else 2*X()-o.renderingStartTime>wn&&n!==1073741824&&(t.flags|=128,r=!0,Tn(o,!1),t.lanes=4194304);o.isBackwards?(u.sibling=t.child,t.child=u):(n=o.last,n!==null?n.sibling=u:t.child=u,o.last=u)}return o.tail!==null?(t=o.tail,o.rendering=t,o.tail=t.sibling,o.renderingStartTime=X(),t.sibling=null,n=W.current,U(W,r?n&1|2:n&1),t):(se(t),null);case 22:case 23:return Uu(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?Se&1073741824&&(se(t),t.subtreeFlags&6&&(t.flags|=8192)):se(t),null;case 24:return null;case 25:return null}throw Error(w(156,t.tag))}function Md(e,t){switch(gu(t),t.tag){case 1:return we(t.type)&&qr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return yn(),$(ge),$(ce),Pu(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Nu(t),null;case 13:if($(W),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(w(340));hn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return $(W),null;case 4:return yn(),null;case 10:return xu(t.type._context),null;case 22:case 23:return Uu(),null;case 24:return null;default:return null}}var jr=!1,ae=!1,Dd=typeof WeakSet=="function"?WeakSet:Set,_=null;function ln(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Y(e,t,r)}else n.current=null}function Ho(e,t,n){try{n()}catch(r){Y(e,t,r)}}var Yi=!1;function Id(e,t){if(No=Xr,e=oa(),vu(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break e}var u=0,i=-1,s=-1,c=0,y=0,v=e,m=null;t:for(;;){for(var k;v!==n||l!==0&&v.nodeType!==3||(i=u+l),v!==o||r!==0&&v.nodeType!==3||(s=u+r),v.nodeType===3&&(u+=v.nodeValue.length),(k=v.firstChild)!==null;)m=v,v=k;for(;;){if(v===e)break t;if(m===n&&++c===l&&(i=u),m===o&&++y===r&&(s=u),(k=v.nextSibling)!==null)break;v=m,m=v.parentNode}v=k}n=i===-1||s===-1?null:{start:i,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(Po={focusedElem:e,selectionRange:n},Xr=!1,_=t;_!==null;)if(t=_,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,_=e;else for(;_!==null;){t=_;try{var S=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(S!==null){var x=S.memoizedProps,L=S.memoizedState,f=t.stateNode,a=f.getSnapshotBeforeUpdate(t.elementType===t.type?x:Fe(t.type,x),L);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(w(163))}}catch(g){Y(t,t.return,g)}if(e=t.sibling,e!==null){e.return=t.return,_=e;break}_=t.return}return S=Yi,Yi=!1,S}function Hn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&Ho(t,n,o)}l=l.next}while(l!==r)}}function xl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Wo(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function tc(e){var t=e.alternate;t!==null&&(e.alternate=null,tc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Qe],delete t[nr],delete t[To],delete t[gd],delete t[wd])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function nc(e){return e.tag===5||e.tag===3||e.tag===4}function Xi(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||nc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Qo(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Jr));else if(r!==4&&(e=e.child,e!==null))for(Qo(e,t,n),e=e.sibling;e!==null;)Qo(e,t,n),e=e.sibling}function Ko(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Ko(e,t,n),e=e.sibling;e!==null;)Ko(e,t,n),e=e.sibling}var le=null,Ue=!1;function ut(e,t,n){for(n=n.child;n!==null;)rc(e,t,n),n=n.sibling}function rc(e,t,n){if(Ke&&typeof Ke.onCommitFiberUnmount=="function")try{Ke.onCommitFiberUnmount(ml,n)}catch{}switch(n.tag){case 5:ae||ln(n,t);case 6:var r=le,l=Ue;le=null,ut(e,t,n),le=r,Ue=l,le!==null&&(Ue?(e=le,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):le.removeChild(n.stateNode));break;case 18:le!==null&&(Ue?(e=le,n=n.stateNode,e.nodeType===8?Yl(e.parentNode,n):e.nodeType===1&&Yl(e,n),Jn(e)):Yl(le,n.stateNode));break;case 4:r=le,l=Ue,le=n.stateNode.containerInfo,Ue=!0,ut(e,t,n),le=r,Ue=l;break;case 0:case 11:case 14:case 15:if(!ae&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,u=o.destroy;o=o.tag,u!==void 0&&(o&2||o&4)&&Ho(n,t,u),l=l.next}while(l!==r)}ut(e,t,n);break;case 1:if(!ae&&(ln(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){Y(n,t,i)}ut(e,t,n);break;case 21:ut(e,t,n);break;case 22:n.mode&1?(ae=(r=ae)||n.memoizedState!==null,ut(e,t,n),ae=r):ut(e,t,n);break;default:ut(e,t,n)}}function Gi(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Dd),t.forEach(function(r){var l=Qd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function De(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=u),r&=~o}if(r=l,r=X()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Ud(r/1960))-r,10e?16:e,dt===null)var r=!1;else{if(e=dt,dt=null,cl=0,D&6)throw Error(w(331));var l=D;for(D|=4,_=e.current;_!==null;){var o=_,u=o.child;if(_.flags&16){var i=o.deletions;if(i!==null){for(var s=0;sX()-Iu?Mt(e,0):Du|=n),ke(e,t)}function fc(e,t){t===0&&(e.mode&1?(t=wr,wr<<=1,!(wr&130023424)&&(wr=4194304)):t=1);var n=de();e=tt(e,t),e!==null&&(cr(e,t,n),ke(e,n))}function Wd(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),fc(e,n)}function Qd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(w(314))}r!==null&&r.delete(t),fc(e,n)}var dc;dc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||ge.current)ye=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return ye=!1,Rd(e,t,n);ye=!!(e.flags&131072)}else ye=!1,H&&t.flags&1048576&&va(t,tl,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Br(e,t),e=t.pendingProps;var l=mn(t,ce.current);fn(t,n),l=zu(null,t,r,e,l,n);var o=Tu();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,we(r)?(o=!0,br(t)):o=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Cu(t),l.updater=Sl,t.stateNode=l,l._reactInternals=t,Io(t,r,e,n),t=Bo(null,t,r,!0,o,n)):(t.tag=0,H&&o&&yu(t),fe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Br(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Yd(r),e=Fe(r,e),l){case 0:t=Uo(null,t,r,e,n);break e;case 1:t=Wi(null,t,r,e,n);break e;case 11:t=Ai(null,t,r,e,n);break e;case 14:t=Hi(null,t,r,Fe(r.type,e),n);break e}throw Error(w(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Fe(r,l),Uo(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Fe(r,l),Wi(e,t,r,l,n);case 3:e:{if(Ga(t),e===null)throw Error(w(387));r=t.pendingProps,o=t.memoizedState,l=o.element,xa(e,t),ll(t,r,null,n);var u=t.memoizedState;if(r=u.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){l=gn(Error(w(423)),t),t=Qi(e,t,r,n,l);break e}else if(r!==l){l=gn(Error(w(424)),t),t=Qi(e,t,r,n,l);break e}else for(xe=vt(t.stateNode.containerInfo.firstChild),Ee=t,H=!0,Be=null,n=ka(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(hn(),r===l){t=nt(e,t,n);break e}fe(e,t,r,n)}t=t.child}return t;case 5:return Ea(t),e===null&&Oo(t),r=t.type,l=t.pendingProps,o=e!==null?e.memoizedProps:null,u=l.children,jo(r,l)?u=null:o!==null&&jo(r,o)&&(t.flags|=32),Xa(e,t),fe(e,t,u,n),t.child;case 6:return e===null&&Oo(t),null;case 13:return Za(e,t,n);case 4:return _u(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=vn(t,null,r,n):fe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Fe(r,l),Ai(e,t,r,l,n);case 7:return fe(e,t,t.pendingProps,n),t.child;case 8:return fe(e,t,t.pendingProps.children,n),t.child;case 12:return fe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,o=t.memoizedProps,u=l.value,U(nl,r._currentValue),r._currentValue=u,o!==null)if(Ae(o.value,u)){if(o.children===l.children&&!ge.current){t=nt(e,t,n);break e}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var i=o.dependencies;if(i!==null){u=o.child;for(var s=i.firstContext;s!==null;){if(s.context===r){if(o.tag===1){s=qe(-1,n&-n),s.tag=2;var c=o.updateQueue;if(c!==null){c=c.shared;var y=c.pending;y===null?s.next=s:(s.next=y.next,y.next=s),c.pending=s}}o.lanes|=n,s=o.alternate,s!==null&&(s.lanes|=n),Mo(o.return,n,t),i.lanes|=n;break}s=s.next}}else if(o.tag===10)u=o.type===t.type?null:o.child;else if(o.tag===18){if(u=o.return,u===null)throw Error(w(341));u.lanes|=n,i=u.alternate,i!==null&&(i.lanes|=n),Mo(u,n,t),u=o.sibling}else u=o.child;if(u!==null)u.return=o;else for(u=o;u!==null;){if(u===t){u=null;break}if(o=u.sibling,o!==null){o.return=u.return,u=o;break}u=u.return}o=u}fe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,fn(t,n),l=Le(l),r=r(l),t.flags|=1,fe(e,t,r,n),t.child;case 14:return r=t.type,l=Fe(r,t.pendingProps),l=Fe(r.type,l),Hi(e,t,r,l,n);case 15:return Ka(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Fe(r,l),Br(e,t),t.tag=1,we(r)?(e=!0,br(t)):e=!1,fn(t,n),Ha(t,r,l),Io(t,r,l,n),Bo(null,t,r,!0,e,n);case 19:return Ja(e,t,n);case 22:return Ya(e,t,n)}throw Error(w(156,t.tag))};function pc(e,t){return $s(e,t)}function Kd(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function ze(e,t,n,r){return new Kd(e,t,n,r)}function $u(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Yd(e){if(typeof e=="function")return $u(e)?1:0;if(e!=null){if(e=e.$$typeof,e===ou)return 11;if(e===uu)return 14}return 2}function kt(e,t){var n=e.alternate;return n===null?(n=ze(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Ar(e,t,n,r,l,o){var u=2;if(r=e,typeof e=="function")$u(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case Gt:return Dt(n.children,l,o,t);case lu:u=8,l|=8;break;case oo:return e=ze(12,n,t,l|2),e.elementType=oo,e.lanes=o,e;case uo:return e=ze(13,n,t,l),e.elementType=uo,e.lanes=o,e;case io:return e=ze(19,n,t,l),e.elementType=io,e.lanes=o,e;case Es:return Cl(n,l,o,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Ss:u=10;break e;case xs:u=9;break e;case ou:u=11;break e;case uu:u=14;break e;case it:u=16,r=null;break e}throw Error(w(130,e==null?e:typeof e,""))}return t=ze(u,n,t,l),t.elementType=e,t.type=r,t.lanes=o,t}function Dt(e,t,n,r){return e=ze(7,e,r,t),e.lanes=n,e}function Cl(e,t,n,r){return e=ze(22,e,r,t),e.elementType=Es,e.lanes=n,e.stateNode={isHidden:!1},e}function to(e,t,n){return e=ze(6,e,null,t),e.lanes=n,e}function no(e,t,n){return t=ze(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Xd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Il(0),this.expirationTimes=Il(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Il(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Vu(e,t,n,r,l,o,u,i,s){return e=new Xd(e,t,n,i,s),t===1?(t=1,o===!0&&(t|=8)):t=0,o=ze(3,null,null,t),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Cu(o),e}function Gd(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(yc)}catch(e){console.error(e)}}yc(),ys.exports=_e;var ep=ys.exports,rs=ep;ro.createRoot=rs.createRoot,ro.hydrateRoot=rs.hydrateRoot;const ls={enabled:!1,available:!1,total_objects:0,total_bytes:0,recent_objects_24h:0,recent_bytes_24h:0,buckets:[]};async function Ie(e,t){const n=await fetch(e,t),r=await n.text();let l={};if(r.trim()!=="")try{l=JSON.parse(r)}catch{l={error:r}}if(!n.ok){const o=typeof l=="object"&&l!==null&&"error"in l?String(l.error):`${n.status} ${n.statusText}`;throw new Error(o)}return l}function Yt(e){if(!e||e<=0)return"0 B";const t=["B","KiB","MiB","GiB","TiB"];let n=e,r=0;for(;n>=1024&&rString(l).padStart(2,"0"),r=[t.getUTCFullYear(),n(t.getUTCMonth()+1),n(t.getUTCDate()),n(t.getUTCHours()),n(t.getUTCMinutes())].join("");return`restore-${e}-${r}`.toLowerCase().replace(/[^a-z0-9-]/g,"-").replace(/-+/g,"-").replace(/^-|-$/g,"").slice(0,63).replace(/-+$/g,"")}function os(){const e=new Date,t=r=>String(r).padStart(2,"0");return`restore-${[e.getUTCFullYear(),t(e.getUTCMonth()+1),t(e.getUTCDate()),t(e.getUTCHours()),t(e.getUTCMinutes())].join("")}-`}function np(){const[e,t]=F.useState(null),[n,r]=F.useState(""),[l,o]=F.useState(null),[u,i]=F.useState(""),[s,c]=F.useState([]),[y,v]=F.useState(""),[m,k]=F.useState(ls),[S,x]=F.useState(""),[L,f]=F.useState({kind:"none"}),[a,d]=F.useState(""),[g,E]=F.useState(""),[N,P]=F.useState(""),[j,V]=F.useState(""),[O,he]=F.useState(os()),[lt,ot]=F.useState(""),[Nt,En]=F.useState(""),[Ht,Wt]=F.useState(""),[C,T]=F.useState(24),[R,A]=F.useState(!0),[G,Pt]=F.useState("No action yet."),[b,Z]=F.useState(!1),Oe=F.useMemo(()=>l?l.namespaces.map(h=>h.name):[],[l]),J=h=>{if(typeof h=="string"){Pt(h);return}Pt(JSON.stringify(h,null,2))},gc=async()=>{try{const h=await Ie("/v1/whoami");t(h),r("")}catch(h){t(null),r(h instanceof Error?h.message:"failed to load auth")}},jt=async()=>{try{const h=await Ie("/v1/inventory");o(h),i(""),!Nt&&h.namespaces.length>0&&En(h.namespaces[0].name)}catch(h){o(null),i(h instanceof Error?h.message:"failed to load inventory")}},zl=async()=>{try{const h=await Ie("/v1/policies");c(h.policies||[]),v("")}catch(h){c([]),v(h instanceof Error?h.message:"failed to load policies")}},Qt=async()=>{try{const h=await Ie("/v1/b2");k(h),x("")}catch(h){k(ls),x(h instanceof Error?h.message:"failed to load B2 usage")}},Qu=async()=>{Z(!0);try{await Promise.all([gc(),jt(),zl(),Qt()])}finally{Z(!1)}};F.useEffect(()=>{Qu()},[]);const wc=async(h,z)=>{Z(!0);try{const Me=await Ie("/v1/backup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({namespace:h,pvc:z,dry_run:!1})});J(Me),await Promise.all([jt(),Qt()])}catch(Me){J({error:Me instanceof Error?Me.message:"backup request failed",namespace:h,pvc:z})}finally{Z(!1)}},kc=async h=>{Z(!0);try{const z=await Ie("/v1/backup/namespace",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({namespace:h,dry_run:!1})});J(z),await Promise.all([jt(),Qt()])}catch(z){J({error:z instanceof Error?z.message:"namespace backup failed",namespace:h})}finally{Z(!1)}},Sc=async(h,z)=>{Z(!0);try{const Me=await Ie(`/v1/backups?namespace=${encodeURIComponent(h)}&pvc=${encodeURIComponent(z)}`),Tl=Me.backups.filter(Xu=>Xu.state==="Completed"&&Xu.url);f({kind:"pvc",namespace:h,pvc:z,volume:Me.volume,backups:Tl}),d(h),E(tp(z)),P(Tl.length>0?String(Tl[0].url):""),J(Me)}catch(Me){J({error:Me instanceof Error?Me.message:"failed to load backups",namespace:h,pvc:z})}finally{Z(!1)}},xc=h=>{f({kind:"namespace",namespace:h}),V(h),he(os()),ot(""),J(`Namespace restore planner loaded for ${h}.`)},Ku=async h=>{if(L.kind==="pvc"){Z(!0);try{const z=await Ie("/v1/restores",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({namespace:L.namespace,pvc:L.pvc,backup_url:N,target_namespace:a,target_pvc:g,dry_run:h})});J(z),await Promise.all([jt(),Qt()])}catch(z){J({error:z instanceof Error?z.message:"restore failed",namespace:L.namespace,pvc:L.pvc,target_namespace:a,target_pvc:g,dry_run:h})}finally{Z(!1)}}},Yu=async h=>{if(L.kind==="namespace"){Z(!0);try{const z=await Ie("/v1/restores/namespace",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({namespace:L.namespace,target_namespace:j,target_prefix:O,snapshot:lt,dry_run:h})});J(z),await Promise.all([jt(),Qt()])}catch(z){J({error:z instanceof Error?z.message:"namespace restore failed",namespace:L.namespace,target_namespace:j,target_prefix:O,dry_run:h})}finally{Z(!1)}}},Ec=async()=>{Z(!0);try{const h=await Ie("/v1/policies",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({namespace:Nt,pvc:Ht,interval_hours:C,enabled:R})});J(h),await Promise.all([zl(),jt()])}catch(h){J({error:h instanceof Error?h.message:"policy save failed"})}finally{Z(!1)}},Cc=async h=>{Z(!0);try{const z=await Ie(`/v1/policies/${encodeURIComponent(h)}`,{method:"DELETE"});J(z),await Promise.all([zl(),jt()])}catch(z){J({error:z instanceof Error?z.message:"policy delete failed",policy_id:h})}finally{Z(!1)}},_c=e?`${e.user||e.email||"authenticated"} (${(e.groups||[]).join(", ")||"no groups"})`:n||"anonymous";return p.jsxs("div",{className:"app-shell",children:[p.jsxs("header",{className:"topbar",children:[p.jsxs("div",{children:[p.jsx("h1",{children:"Soteria Backup Console"}),p.jsx("p",{className:"subtle",children:"Dark-mode React UI for backup drills, policy control, and B2 consumption visibility."})]}),p.jsxs("div",{className:"toolbar",children:[p.jsx("span",{className:`chip ${e?"good":"warn"}`,children:_c}),p.jsx("button",{type:"button",className:"secondary",onClick:()=>void Qu(),disabled:b,children:b?"Refreshing...":"Refresh"})]})]}),p.jsxs("main",{className:"layout",children:[p.jsxs("section",{className:"panel scroll-panel",children:[p.jsxs("div",{className:"panel-header",children:[p.jsx("h2",{children:"PVC Inventory"}),p.jsx("span",{className:"subtle",children:l!=null&&l.generated_at?`Updated ${Rn(l.generated_at)}`:"No inventory yet"})]}),u&&p.jsx("p",{className:"error",children:u}),!l&&!u&&p.jsx("p",{className:"subtle",children:"Loading inventory..."}),l==null?void 0:l.namespaces.map(h=>p.jsxs("article",{className:"namespace-block",children:[p.jsxs("div",{className:"namespace-row",children:[p.jsx("h3",{children:h.name}),p.jsxs("div",{className:"actions",children:[p.jsx("button",{type:"button",className:"secondary",onClick:()=>void kc(h.name),disabled:b,children:"Backup namespace"}),p.jsx("button",{type:"button",className:"secondary",onClick:()=>xc(h.name),children:"Restore namespace"})]})]}),p.jsx("div",{className:"pvc-grid",children:h.pvcs.map(z=>p.jsxs("article",{className:"pvc-card",children:[p.jsxs("div",{className:"pvc-title-row",children:[p.jsxs("div",{children:[p.jsx("h4",{children:z.pvc}),p.jsxs("p",{className:"subtle tiny",children:[z.volume||"unknown volume"," | ",z.storage_class||"no class"," | ",z.capacity||"unknown size"]})]}),p.jsx("span",{className:`chip ${z.healthy?"good":"bad"}`,children:z.healthy?"Healthy":z.health_reason||"Needs attention"})]}),p.jsxs("p",{className:"subtle tiny",children:["Last backup: ",z.last_backup_at?`${Rn(z.last_backup_at)} (${(z.last_backup_age_hours||0).toFixed(1)}h ago)`:"never"]}),p.jsxs("p",{className:"subtle tiny",children:["Backups: ",z.completed_backups,"/",z.backup_count," completed | Latest size: ",Yt(z.last_backup_size_bytes)," | Total stored: ",Yt(z.total_backup_size_bytes)]}),z.error&&p.jsx("p",{className:"error tiny",children:z.error}),p.jsxs("div",{className:"actions",children:[p.jsx("button",{type:"button",onClick:()=>void wc(z.namespace,z.pvc),disabled:b,children:"Backup now"}),p.jsx("button",{type:"button",className:"secondary",onClick:()=>void Sc(z.namespace,z.pvc),children:"Restore"})]})]},`${z.namespace}/${z.pvc}`))})]},h.name))]}),p.jsxs("section",{className:"column",children:[p.jsxs("section",{className:"panel",children:[p.jsx("h2",{children:"Restore Planner"}),p.jsx("p",{className:"subtle tiny",children:"Inventory restore buttons preload this panel. This is where restore dry-runs and execution happen."}),L.kind==="none"&&p.jsx("p",{className:"subtle",children:"Choose Restore on a PVC or namespace to begin."}),L.kind==="pvc"&&p.jsxs("div",{className:"stack",children:[p.jsxs("p",{className:"subtle tiny",children:[p.jsx("strong",{children:"Source:"})," ",L.namespace,"/",L.pvc," (",L.volume,")"]}),p.jsxs("label",{children:["Backup snapshot",p.jsxs("select",{value:N,onChange:h=>P(h.target.value),children:[L.backups.length===0&&p.jsx("option",{value:"",children:"No completed backups"}),L.backups.map(h=>p.jsxs("option",{value:h.url||"",children:[h.name," | ",h.created||"unknown time"," | ",h.size||"size n/a"]},h.url||h.name))]})]}),p.jsxs("label",{children:["Target namespace",p.jsx("select",{value:a,onChange:h=>d(h.target.value),children:Oe.map(h=>p.jsx("option",{value:h,children:h},h))})]}),p.jsxs("label",{children:["Target PVC",p.jsx("input",{value:g,onChange:h=>E(h.target.value)})]}),p.jsxs("div",{className:"actions",children:[p.jsx("button",{type:"button",onClick:()=>void Ku(!1),disabled:b||!N,children:"Create restore PVC"}),p.jsx("button",{type:"button",className:"secondary",onClick:()=>void Ku(!0),disabled:b||!N,children:"Dry run"})]})]}),L.kind==="namespace"&&p.jsxs("div",{className:"stack",children:[p.jsxs("p",{className:"subtle tiny",children:[p.jsx("strong",{children:"Source namespace:"})," ",L.namespace]}),p.jsxs("label",{children:["Target namespace",p.jsx("select",{value:j,onChange:h=>V(h.target.value),children:Oe.map(h=>p.jsx("option",{value:h,children:h},h))})]}),p.jsxs("label",{children:["Target PVC prefix",p.jsx("input",{value:O,onChange:h=>he(h.target.value)})]}),p.jsxs("label",{children:["Snapshot hint (optional)",p.jsx("input",{value:lt,onChange:h=>ot(h.target.value),placeholder:"blank = latest completed"})]}),p.jsxs("div",{className:"actions",children:[p.jsx("button",{type:"button",onClick:()=>void Yu(!1),disabled:b,children:"Create restore PVCs"}),p.jsx("button",{type:"button",className:"secondary",onClick:()=>void Yu(!0),disabled:b,children:"Dry run"})]})]})]}),p.jsxs("section",{className:"panel action-panel",children:[p.jsx("h2",{children:"Last Action"}),p.jsx("pre",{children:G})]})]}),p.jsxs("section",{className:"column",children:[p.jsxs("section",{className:"panel",children:[p.jsxs("div",{className:"panel-header",children:[p.jsx("h2",{children:"B2 Consumption"}),p.jsx("button",{type:"button",className:"secondary",onClick:()=>void Qt(),disabled:b,children:"Refresh B2"})]}),S&&p.jsx("p",{className:"error",children:S}),!S&&!m.enabled&&p.jsx("p",{className:"subtle",children:"B2 monitoring is disabled in Soteria config."}),!S&&m.enabled&&!m.available&&p.jsx("p",{className:"error",children:m.error||"B2 usage currently unavailable."}),m.enabled&&p.jsxs("div",{className:"stack",children:[p.jsxs("p",{className:"subtle tiny",children:["Endpoint: ",m.endpoint||"n/a"," | Region: ",m.region||"n/a"]}),p.jsxs("p",{className:"subtle tiny",children:["Last scan: ",Rn(m.scanned_at)," | Duration: ",m.scan_duration_ms||0,"ms"]}),p.jsxs("div",{className:"stat-grid",children:[p.jsxs("div",{className:"stat",children:[p.jsx("span",{className:"label",children:"Stored bytes"}),p.jsx("strong",{children:Yt(m.total_bytes)})]}),p.jsxs("div",{className:"stat",children:[p.jsx("span",{className:"label",children:"Objects"}),p.jsx("strong",{children:m.total_objects})]}),p.jsxs("div",{className:"stat",children:[p.jsx("span",{className:"label",children:"Recent bytes (24h)"}),p.jsx("strong",{children:Yt(m.recent_bytes_24h)})]}),p.jsxs("div",{className:"stat",children:[p.jsx("span",{className:"label",children:"Recent objects (24h)"}),p.jsx("strong",{children:m.recent_objects_24h})]})]}),p.jsx("p",{className:"subtle tiny",children:"Recent 24h values are object-change bandwidth proxy from bucket scans. B2 egress billing totals are not exposed by S3 object listing."}),p.jsx("div",{className:"bucket-table-wrap",children:p.jsxs("table",{children:[p.jsx("thead",{children:p.jsxs("tr",{children:[p.jsx("th",{children:"Bucket"}),p.jsx("th",{children:"Objects"}),p.jsx("th",{children:"Stored"}),p.jsx("th",{children:"Recent 24h"})]})}),p.jsx("tbody",{children:(m.buckets||[]).map(h=>p.jsxs("tr",{children:[p.jsxs("td",{children:[p.jsx("div",{children:h.name}),p.jsxs("div",{className:"subtle tiny",children:["Last object: ",Rn(h.last_modified_at)]})]}),p.jsx("td",{children:h.object_count}),p.jsx("td",{children:Yt(h.total_bytes)}),p.jsx("td",{children:Yt(h.recent_bytes_24h)})]},h.name))})]})})]})]}),p.jsxs("section",{className:"panel scroll-panel",children:[p.jsx("h2",{children:"Backup Policies"}),p.jsxs("div",{className:"stack",children:[p.jsxs("label",{children:["Namespace",p.jsx("select",{value:Nt,onChange:h=>En(h.target.value),children:Oe.map(h=>p.jsx("option",{value:h,children:h},h))})]}),p.jsxs("label",{children:["PVC (optional)",p.jsx("input",{value:Ht,onChange:h=>Wt(h.target.value),placeholder:"blank means all PVCs in namespace"})]}),p.jsxs("label",{children:["Interval hours",p.jsx("input",{type:"number",min:1,value:C,onChange:h=>T(Math.max(1,Number(h.target.value||1)))})]}),p.jsxs("label",{className:"checkbox-row",children:[p.jsx("input",{type:"checkbox",checked:R,onChange:h=>A(h.target.checked)}),"Enabled"]}),p.jsx("button",{type:"button",onClick:()=>void Ec(),disabled:b||!Nt,children:"Save policy"})]}),y&&p.jsx("p",{className:"error",children:y}),!y&&s.length===0&&p.jsx("p",{className:"subtle",children:"No policies yet."}),p.jsx("div",{className:"policy-list",children:s.map(h=>p.jsxs("article",{className:"policy-item",children:[p.jsxs("div",{className:"policy-head",children:[p.jsxs("strong",{children:[h.namespace,"/",h.pvc||"*"]}),p.jsx("span",{className:`chip ${h.enabled?"good":"bad"}`,children:h.enabled?"Enabled":"Disabled"})]}),p.jsxs("p",{className:"subtle tiny",children:["Every ",h.interval_hours,"h | Updated ",Rn(h.updated_at||h.created_at)]}),p.jsxs("div",{className:"actions",children:[p.jsx("button",{type:"button",className:"secondary",onClick:()=>{En(h.namespace),Wt(h.pvc||""),T(h.interval_hours),A(h.enabled)},children:"Load"}),p.jsx("button",{type:"button",className:"secondary",onClick:()=>void Cc(h.id),disabled:b,children:"Delete"})]})]},h.id))})]})]})]})]})}ro.createRoot(document.getElementById("root")).render(p.jsx(Ac.StrictMode,{children:p.jsx(np,{})})); diff --git a/internal/server/ui-dist/assets/index-Dq7_oHb5.css b/internal/server/ui-dist/assets/index-Dq7_oHb5.css new file mode 100644 index 0000000..6930c36 --- /dev/null +++ b/internal/server/ui-dist/assets/index-Dq7_oHb5.css @@ -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}} diff --git a/internal/server/ui-dist/index.html b/internal/server/ui-dist/index.html new file mode 100644 index 0000000..68a1929 --- /dev/null +++ b/internal/server/ui-dist/index.html @@ -0,0 +1,13 @@ + + + + + + Soteria Backup Console + + + + +
+ + diff --git a/internal/server/ui.go b/internal/server/ui.go deleted file mode 100644 index f0bf955..0000000 --- a/internal/server/ui.go +++ /dev/null @@ -1,6 +0,0 @@ -package server - -import _ "embed" - -//go:embed ui.html -var uiHTML string diff --git a/internal/server/ui.html b/internal/server/ui.html deleted file mode 100644 index e2824b9..0000000 --- a/internal/server/ui.html +++ /dev/null @@ -1,574 +0,0 @@ - - - - - - Soteria Backup Console - - - -
-
-
-

Soteria Backup Console

-

Namespace-grouped PVC backup and restore control plane for Atlas.

-
-
- Checking access... - -
-
-
-
-
-
-

PVC Inventory

- -
-
-

Loading PVC inventory...

-
-
- -
- - - diff --git a/internal/server/ui_renderer.go b/internal/server/ui_renderer.go new file mode 100644 index 0000000..c62ce6b --- /dev/null +++ b/internal/server/ui_renderer.go @@ -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 +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e985988 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Soteria Backup Console + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..5e716bd --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1729 @@ +{ + "name": "soteria-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "soteria-ui", + "version": "0.1.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..afef64a --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..d9444cd --- /dev/null +++ b/web/src/App.tsx @@ -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(input: string, init?: RequestInit): Promise { + 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(null); + const [authError, setAuthError] = useState(''); + + const [inventory, setInventory] = useState(null); + const [inventoryError, setInventoryError] = useState(''); + + const [policies, setPolicies] = useState([]); + const [policyError, setPolicyError] = useState(''); + + const [b2Usage, setB2Usage] = useState(EMPTY_B2); + const [b2Error, setB2Error] = useState(''); + + const [selection, setSelection] = useState({ kind: 'none' }); + const [restoreNamespace, setRestoreNamespace] = useState(''); + const [restorePVC, setRestorePVC] = useState(''); + const [restoreBackupURL, setRestoreBackupURL] = useState(''); + + const [namespaceRestoreTarget, setNamespaceRestoreTarget] = useState(''); + const [namespaceRestorePrefix, setNamespaceRestorePrefix] = useState(suggestNamespacePrefix()); + const [namespaceRestoreSnapshot, setNamespaceRestoreSnapshot] = useState(''); + + const [policyNamespace, setPolicyNamespace] = useState(''); + const [policyPVC, setPolicyPVC] = useState(''); + const [policyIntervalHours, setPolicyIntervalHours] = useState(24); + const [policyEnabled, setPolicyEnabled] = useState(true); + + const [lastAction, setLastAction] = useState('No action yet.'); + const [busy, setBusy] = useState(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 => { + try { + const who = await fetchJSON('/v1/whoami'); + setAuth(who); + setAuthError(''); + } catch (error) { + setAuth(null); + setAuthError(error instanceof Error ? error.message : 'failed to load auth'); + } + }; + + const loadInventory = async (): Promise => { + try { + const payload = await fetchJSON('/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 => { + try { + const payload = await fetchJSON('/v1/policies'); + setPolicies(payload.policies || []); + setPolicyError(''); + } catch (error) { + setPolicies([]); + setPolicyError(error instanceof Error ? error.message : 'failed to load policies'); + } + }; + + const loadB2Usage = async (): Promise => { + try { + const payload = await fetchJSON('/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 => { + setBusy(true); + try { + await Promise.all([loadWhoAmI(), loadInventory(), loadPolicies(), loadB2Usage()]); + } finally { + setBusy(false); + } + }; + + useEffect(() => { + void refreshAll(); + }, []); + + const triggerBackup = async (namespace: string, pvc: string): Promise => { + setBusy(true); + try { + const payload = await fetchJSON('/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 => { + setBusy(true); + try { + const payload = await fetchJSON('/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 => { + setBusy(true); + try { + const payload = await fetchJSON(`/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 => { + if (selection.kind !== 'pvc') { + return; + } + setBusy(true); + try { + const payload = await fetchJSON('/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 => { + if (selection.kind !== 'namespace') { + return; + } + setBusy(true); + try { + const payload = await fetchJSON('/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 => { + setBusy(true); + try { + const payload = await fetchJSON('/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 => { + setBusy(true); + try { + const payload = await fetchJSON(`/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 ( +
+
+
+

Soteria Backup Console

+

Dark-mode React UI for backup drills, policy control, and B2 consumption visibility.

+
+
+ {authLabel} + +
+
+ +
+
+
+

PVC Inventory

+ {inventory?.generated_at ? `Updated ${formatTimestamp(inventory.generated_at)}` : 'No inventory yet'} +
+ {inventoryError &&

{inventoryError}

} + {!inventory && !inventoryError &&

Loading inventory...

} + {inventory?.namespaces.map((namespace) => ( +
+
+

{namespace.name}

+
+ + +
+
+
+ {namespace.pvcs.map((pvc) => ( +
+
+
+

{pvc.pvc}

+

{pvc.volume || 'unknown volume'} | {pvc.storage_class || 'no class'} | {pvc.capacity || 'unknown size'}

+
+ + {pvc.healthy ? 'Healthy' : pvc.health_reason || 'Needs attention'} + +
+

+ Last backup: {pvc.last_backup_at ? `${formatTimestamp(pvc.last_backup_at)} (${(pvc.last_backup_age_hours || 0).toFixed(1)}h ago)` : 'never'} +

+

+ Backups: {pvc.completed_backups}/{pvc.backup_count} completed | Latest size: {formatBytes(pvc.last_backup_size_bytes)} | Total stored: {formatBytes(pvc.total_backup_size_bytes)} +

+ {pvc.error &&

{pvc.error}

} +
+ + +
+
+ ))} +
+
+ ))} +
+ +
+
+

Restore Planner

+

Inventory restore buttons preload this panel. This is where restore dry-runs and execution happen.

+ {selection.kind === 'none' &&

Choose Restore on a PVC or namespace to begin.

} + + {selection.kind === 'pvc' && ( +
+

Source: {selection.namespace}/{selection.pvc} ({selection.volume})

+ + + +
+ + +
+
+ )} + + {selection.kind === 'namespace' && ( +
+

Source namespace: {selection.namespace}

+ + + +
+ + +
+
+ )} +
+ +
+

Last Action

+
{lastAction}
+
+
+ +
+
+
+

B2 Consumption

+ +
+ {b2Error &&

{b2Error}

} + {!b2Error && !b2Usage.enabled &&

B2 monitoring is disabled in Soteria config.

} + {!b2Error && b2Usage.enabled && !b2Usage.available &&

{b2Usage.error || 'B2 usage currently unavailable.'}

} + {b2Usage.enabled && ( +
+

Endpoint: {b2Usage.endpoint || 'n/a'} | Region: {b2Usage.region || 'n/a'}

+

Last scan: {formatTimestamp(b2Usage.scanned_at)} | Duration: {b2Usage.scan_duration_ms || 0}ms

+
+
+ Stored bytes + {formatBytes(b2Usage.total_bytes)} +
+
+ Objects + {b2Usage.total_objects} +
+
+ Recent bytes (24h) + {formatBytes(b2Usage.recent_bytes_24h)} +
+
+ Recent objects (24h) + {b2Usage.recent_objects_24h} +
+
+

Recent 24h values are object-change bandwidth proxy from bucket scans. B2 egress billing totals are not exposed by S3 object listing.

+
+ + + + + + + + + + + {(b2Usage.buckets || []).map((bucket) => ( + + + + + + + ))} + +
BucketObjectsStoredRecent 24h
+
{bucket.name}
+
Last object: {formatTimestamp(bucket.last_modified_at)}
+
{bucket.object_count}{formatBytes(bucket.total_bytes)}{formatBytes(bucket.recent_bytes_24h)}
+
+
+ )} +
+ +
+

Backup Policies

+
+ + + + + +
+ + {policyError &&

{policyError}

} + {!policyError && policies.length === 0 &&

No policies yet.

} +
+ {policies.map((policy) => ( +
+
+ {policy.namespace}/{policy.pvc || '*'} + {policy.enabled ? 'Enabled' : 'Disabled'} +
+

Every {policy.interval_hours}h | Updated {formatTimestamp(policy.updated_at || policy.created_at)}

+
+ + +
+
+ ))} +
+
+
+
+
+ ); +} + +export default App; diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..abd10b8 --- /dev/null +++ b/web/src/main.tsx @@ -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( + + + +); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..e30ab22 --- /dev/null +++ b/web/src/styles.css @@ -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; + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..f68352b --- /dev/null +++ b/web/tsconfig.json @@ -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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..73729af --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: 'dist', + emptyOutDir: true + } +});