diff --git a/.gitignore b/.gitignore index 1892a58..86d572f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ node_modules/ dist/ *.pem *.key -*.cert \ No newline at end of file +*.cert +/bin/ +.idea \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9f87374..0ff0669 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,13 +8,15 @@ RUN go mod download COPY cmd/ /src/cmd/ COPY internal/ /src/internal/ COPY web/ /src/web/ -COPY internal/migration/ /src/internal/migration/ +COPY internal/migration/ /internal/migration/ RUN go build -o /out/go-mad ./cmd/go-mad FROM gcr.io/distroless/static-debian12:nonroot COPY --from=builder /out/go-mad /go-mad +COPY --from=builder /src/web /src/web +COPY --from=builder /internal/migration /internal/migration EXPOSE 8080 diff --git a/Makefile b/Makefile index c0ca55c..22d135a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build test vet fmt compose-up compose-down logs clean help +.PHONY: all build test vet fmt compose-up compose-down logs clean help web-dev GO_VERSION ?= 1.27.1 BINARY_NAME ?= go-mad @@ -19,7 +19,7 @@ build: fmt vet ## Build the Go binary ## ---- Development ---- run: build ## Run the Go binary (requires postgres running) - @$(BINARY_NAME) + @$(BINARY_DIR)/$(BINARY_NAME) compose-up: ## Start docker compose (postgres + go-mad) docker compose up --build -d @@ -48,12 +48,10 @@ integration-test: ## Run integration tests (requires test database) ## ---- Web Frontend ---- web-build: ## Build React frontend - npm install - npm run build + cd web && npm install && npm run build -web-dev: ## Run React dev server - npm install - npm run dev +web-dev: ## Start the go-mad application with Docker Compose + @docker compose up --build -d ## ---- Docker ---- @@ -75,24 +73,19 @@ help: ## Show this help message @echo "" @echo "Build targets:" @echo " build Build the Go binary" - @echo " run Run the Go binary" + @echo " run Run the Go binary (requires postgres running)" @echo " compose-up Start docker compose" @echo " compose-down Stop docker compose" @echo " compose-logs View docker compose logs" @echo "" - @echo "Code quality:" - @echo " fmt Format Go code" - @echo " vet Vet Go code" - @echo " test Run Go unit tests" + @echo "Web frontend:" + @echo " web-build Build React frontend" + @echo " web-dev Start go-mad with docker compose" @echo "" @echo "Docker:" @echo " docker-build Build docker image" @echo " docker-push Push docker image" @echo "" - @echo "Web frontend:" - @echo " web-build Build React frontend" - @echo " web-dev Run React dev server" - @echo "" @echo "Other:" @echo " clean Remove build artifacts" @echo " help Show this help message" \ No newline at end of file diff --git a/bin/go-mad b/bin/go-mad index 778ac07..d7c513f 100755 Binary files a/bin/go-mad and b/bin/go-mad differ diff --git a/cmd/go-mad/main.go b/cmd/go-mad/main.go index 6d8a946..895a5c5 100644 --- a/cmd/go-mad/main.go +++ b/cmd/go-mad/main.go @@ -1,13 +1,16 @@ package main import ( + "context" "log" + "net/http" "os" "os/signal" "syscall" "github.com/dnoulet/go-mad/internal/config" "github.com/dnoulet/go-mad/internal/database" + "github.com/dnoulet/go-mad/internal/handler" ) func main() { @@ -22,21 +25,39 @@ func main() { } defer db.Close() - if err := database.Migrate(db, "internal/migration"); err != nil { + if err := database.Migrate(db, "/internal/migration"); err != nil { log.Fatalf("Running migrations: %v", err) } + h := handler.New(db) + if err != nil { + log.Fatalf(" initializing handler: %v", err) + } + + httpServer := &http.Server{ + Addr: cfg.Server.Host + ":" + cfg.Server.Port, + Handler: h, + } + // Set up signal handling for graceful shutdown stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - // Start server + // Start HTTP server go func() { log.Printf("Starting server on %s:%s", cfg.Server.Host, cfg.Server.Port) - // TODO: start HTTP server + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTP server failed: %v", err) + } }() - // Wait for interrupt <-stop log.Println("Shutting down gracefully...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*60) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Printf("HTTP server shutdown failed: %v", err) + } + log.Println("Server stopped") } diff --git a/docker-compose.yml b/docker-compose.yml index be338d0..0c61859 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ services: - go-mad-database: + postgres: image: postgres:15-alpine container_name: go-mad-postgres environment: @@ -20,15 +20,13 @@ services: dockerfile: Dockerfile container_name: go-mad depends_on: - go-mad-database: + postgres: condition: service_healthy - extra_hosts: - - "go-mad-database:host-gateway" environment: - GO_MAD_DATABASE_URL: postgres://mad:mad@go-mad-database:5432/mad?sslmode=disable + GO_MAD_DATABASE_URL: postgres://mad:mad@postgres:5432/mad?sslmode=disable GO_MAD_SERVER_PORT: "8080" GO_MAD_JWT_SECRET: change-me-to-a-32-plus-char-secret ports: - - "8081:8080" + - "8080:8080" volumes: - postgres_data: + postgres_data: \ No newline at end of file diff --git a/helm/go-mad/Chart.lock b/helm/go-mad/Chart.lock new file mode 100644 index 0000000..123c87e --- /dev/null +++ b/helm/go-mad/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: postgresql + repository: https://charts.bitnami.com/bitnami + version: 16.7.27 +digest: sha256:e8b999a3aaad6328a13d0c666207650897b69c61367c947d363b2acbe0ea1b42 +generated: "2026-10-04T17:23:41.006015619+02:00" diff --git a/helm/go-mad/Chart.yaml b/helm/go-mad/Chart.yaml index d63106e..1076a21 100644 --- a/helm/go-mad/Chart.yaml +++ b/helm/go-mad/Chart.yaml @@ -3,4 +3,9 @@ name: go-mad description: A web application for managing tenant database connections and migrations type: application version: 0.1.0 -appVersion: "1.0" \ No newline at end of file +appVersion: "1.0" +dependencies: + - name: postgresql + version: "16.x.x" + repository: "https://charts.bitnami.com/bitnami" + condition: postgresql.enabled \ No newline at end of file diff --git a/helm/go-mad/charts/postgresql-16.7.27.tgz b/helm/go-mad/charts/postgresql-16.7.27.tgz new file mode 100644 index 0000000..73dc91a Binary files /dev/null and b/helm/go-mad/charts/postgresql-16.7.27.tgz differ diff --git a/helm/go-mad/templates/_helpers.tpl b/helm/go-mad/templates/_helpers.tpl index f203077..6587a19 100644 --- a/helm/go-mad/templates/_helpers.tpl +++ b/helm/go-mad/templates/_helpers.tpl @@ -3,5 +3,9 @@ go-mad {{- end -}} {{- define "go-mad.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride -}} +{{- else -}} {{- .Release.Name -}} +{{- end -}} {{- end -}} \ No newline at end of file diff --git a/helm/go-mad/templates/deployment.yaml b/helm/go-mad/templates/deployment.yaml index 011bec4..c4316e4 100644 --- a/helm/go-mad/templates/deployment.yaml +++ b/helm/go-mad/templates/deployment.yaml @@ -1,5 +1,6 @@ {{- $fullName := include "go-mad.fullname" . }} {{- $values := index .Values "go-mad" }} +{{- $pgName := printf "%s-postgresql" .Release.Name }} apiVersion: apps/v1 kind: Deployment @@ -29,7 +30,7 @@ spec: - containerPort: {{ $values.serverPort | int }} env: - name: GO_MAD_DATABASE_URL - value: "{{ $values.databaseURL }}" + value: "postgres://mad:{{ .Values.postgresql.auth.password }}@{{ $pgName }}:5432/mad?sslmode=disable" - name: GO_MAD_SERVER_PORT value: "{{ $values.serverPort }}" - name: GO_MAD_JWT_SECRET diff --git a/helm/go-mad/templates/ingress.yaml b/helm/go-mad/templates/ingress.yaml new file mode 100644 index 0000000..4c33cc5 --- /dev/null +++ b/helm/go-mad/templates/ingress.yaml @@ -0,0 +1,38 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "go-mad.fullname" . }} +{{- $firstHost := first .Values.ingress.hosts -}} +{{- $firstPath := first $firstHost.paths -}} +{{- $ingressPath := $firstPath.path -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + app.kubernetes.io/name: {{ include "go-mad.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + ingressClassName: {{ .Values.ingress.className }} + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ $fullName }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm/go-mad/values-dev.yaml b/helm/go-mad/values-dev.yaml new file mode 100644 index 0000000..8a5d081 --- /dev/null +++ b/helm/go-mad/values-dev.yaml @@ -0,0 +1,61 @@ +# Development values for go-mad +replicaCount: 1 + +image: + repository: batty1039.startdedicated.net:5000/go-mad + pullPolicy: Always + tag: "latest" + +service: + type: ClusterIP + port: 8080 + +resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + +postgresql: + enabled: true + image: + registry: docker.io + repository: bitnami/postgresql + tag: latest + pullPolicy: IfNotPresent + auth: + username: mad + password: mad + database: mad + primary: + persistence: + enabled: true + size: 2Gi + +go-mad: + serverPort: 8080 + jwtSecret: "dev-jwt-secret-change-me-to-32-plus-chars" + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-staging + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: mad-dev.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-mad-tls + hosts: + - mad-dev.urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-staging + kind: ClusterIssuer \ No newline at end of file diff --git a/helm/go-mad/values-lcl.yaml b/helm/go-mad/values-lcl.yaml new file mode 100644 index 0000000..8b4ba48 --- /dev/null +++ b/helm/go-mad/values-lcl.yaml @@ -0,0 +1,60 @@ +# Local (LCL) values for go-mad. +# Deployed to namespace go-insane-lcl, served at https://mad-lcl.intern-urpi.be +# by the docker TLS edge (dev-infrastructure/config/traefik/tls.yaml). +replicaCount: 1 + +image: + repository: localhost:5000/go-mad + pullPolicy: IfNotPresent + tag: "0.0.1" + +service: + type: ClusterIP + port: 8080 + +resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + +postgresql: + enabled: true + image: + registry: docker.io + repository: bitnami/postgresql + tag: latest + pullPolicy: IfNotPresent + auth: + username: mad + password: mad + database: mad + primary: + persistence: + enabled: true + size: 2Gi + +go-mad: + serverPort: 8080 + jwtSecret: "lcl-jwt-secret-change-me-to-32-plus-chars" + +ingress: + enabled: true + className: traefik + annotations: {} + hosts: + - host: mad-lcl.intern-urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: local-dev-cert + hosts: + - mad-lcl.intern-urpi.be + +# No cert-manager in the local cluster. The local CA certificate is provided +# through the local-dev-cert secret (dev-infrastructure/k8s). +certManager: + enabled: false diff --git a/helm/go-mad/values-prd.yaml b/helm/go-mad/values-prd.yaml new file mode 100644 index 0000000..efb6d8d --- /dev/null +++ b/helm/go-mad/values-prd.yaml @@ -0,0 +1,68 @@ +# Production values for go-mad +replicaCount: 2 + +image: + repository: batty1039.startdedicated.net:5000/go-mad + pullPolicy: Always + tag: "latest" + +service: + type: ClusterIP + port: 8080 + +resources: + limits: + cpu: 1000m + memory: 512Mi + requests: + cpu: 200m + memory: 256Mi + +postgresql: + enabled: true + image: + registry: docker.io + repository: bitnami/postgresql + tag: latest + pullPolicy: IfNotPresent + auth: + username: mad + password: mad + database: mad + primary: + persistence: + enabled: true + size: 20Gi + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 200m + memory: 512Mi + +go-mad: + serverPort: 8080 + jwtSecret: "" # Set via --set or secret + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: mad.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-mad-tls + hosts: + - mad.urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer \ No newline at end of file diff --git a/helm/go-mad/values-tst.yaml b/helm/go-mad/values-tst.yaml new file mode 100644 index 0000000..b7c492c --- /dev/null +++ b/helm/go-mad/values-tst.yaml @@ -0,0 +1,61 @@ +# TST values for go-mad +replicaCount: 1 + +image: + repository: batty1039.startdedicated.net:5000/go-mad + pullPolicy: Always + tag: "latest" + +service: + type: ClusterIP + port: 8080 + +resources: + limits: + cpu: 750m + memory: 384Mi + requests: + cpu: 150m + memory: 256Mi + +postgresql: + enabled: true + image: + registry: docker.io + repository: bitnami/postgresql + tag: latest + pullPolicy: IfNotPresent + auth: + username: mad + password: mad + database: mad + primary: + persistence: + enabled: true + size: 5Gi + +go-mad: + serverPort: 8080 + jwtSecret: "tst-jwt-secret-change-me" + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-staging + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: mad-tst.intern-urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-mad-tls + hosts: + - mad-tst.intern-urpi.be + +certManager: + enabled: true + issuerRef: + name: letsencrypt-staging + kind: ClusterIssuer \ No newline at end of file diff --git a/helm/go-mad/values.yaml b/helm/go-mad/values.yaml index 1454cf2..95ff5dc 100644 --- a/helm/go-mad/values.yaml +++ b/helm/go-mad/values.yaml @@ -1,8 +1,11 @@ +# Default values for go-mad +# This is a base configuration - use values-dev.yaml or values-prd.yaml for deployment + replicaCount: 1 image: repository: batty1039.startdedicated.net:5000/go-mad - pullPolicy: IfNotPresent + pullPolicy: Always tag: "latest" service: @@ -17,37 +20,48 @@ resources: cpu: 100m memory: 128Mi -database: +# Database configuration (uses bitnami/postgresql subchart) +postgresql: enabled: true - postgresql: - enabled: true - auth: - username: mad - password: mad + image: + registry: docker.io + repository: bitnami/postgresql + tag: latest + pullPolicy: IfNotPresent + auth: + username: mad + password: mad database: mad + primary: + persistence: + enabled: true + size: 8Gi -ingress: - enabled: false - className: "" - annotations: {} - # kubernetes.io/ingress.class: nginx - # kubernetes.io/tls-related: ... - hosts: - - host: go-mad.local - paths: - - path: / - pathType: ImplementationSpecific - tls: [] - # - secretName: go-mad-tls - # hosts: - # - go-mad.local - +# Application configuration go-mad: - databaseURL: "postgres://mad:mad@go-guess-postgresql:5432/mad?sslmode=disable" serverPort: 8080 jwtSecret: "change-me-to-a-32-plus-char-secret" -web: +# Ingress configuration +ingress: enabled: true - type: serving - port: 8080 \ No newline at end of file + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/ssl-redirect: "true" + hosts: + - host: mad.urpi.be + paths: + - path: / + pathType: Prefix + tls: + - secretName: go-mad-tls + hosts: + - mad.urpi.be + +# Cert-manager configuration +certManager: + enabled: true + issuerRef: + name: letsencrypt-prod + kind: ClusterIssuer \ No newline at end of file diff --git a/internal/database/database.go b/internal/database/database.go index 8cd003f..7a16504 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -57,14 +57,14 @@ func FindMigrations(migrationPath string) ([]Migration, error) { } contentStr := string(content) - migration := parseMigration(contentStr) + migration := parseMigration(contentStr, entry.Name()) migrations = append(migrations, migration) } return migrations, nil } -func parseMigration(content string) Migration { +func parseMigration(content string, filename string) Migration { // Parse goose-style migration: -- +goose Up / -- +goose Down lines := strings.Split(content, "\n") var upSQL strings.Builder @@ -90,9 +90,14 @@ func parseMigration(content string) Migration { // Extract version from filename (first part before _) version := "" desc := "" - if idx := strings.Index(content, "_"); idx >= 0 { - version = content[:idx] - desc = content[idx+1 : strings.Index(content, "_")] // simplistic + if idx := strings.Index(filename, "_"); idx >= 0 { + version = filename[:idx] + remaining := filename[idx+1:] + if idx2 := strings.Index(remaining, "_"); idx2 >= 0 { + desc = remaining[:idx2] + } else { + desc = strings.TrimSuffix(remaining, ".sql") + } } return Migration{ diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..5907d2d --- /dev/null +++ b/internal/handler/handler.go @@ -0,0 +1,34 @@ +package handler + +import ( + "database/sql" + "log" + "net/http" +) + +type Handler struct { + db *sql.DB +} + +func New(db *sql.DB) *Handler { + log.Println("Handler routes registered") + + return &Handler{ + db: db, + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{"status":"ok"}`)) + case "/api/v1/dashboard": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write([]byte(`{"tenants":[],"applications":[],"databases":[],"is_system_administrator":false,"selected_tenant":""}`)) + default: + http.NotFound(w, r) + } +} diff --git a/web/src/index.html b/web/index.html similarity index 68% rename from web/src/index.html rename to web/index.html index 34b746f..6f52ae0 100644 --- a/web/src/index.html +++ b/web/index.html @@ -4,10 +4,10 @@ go-mad - Tenant Database Manager - +
- + \ No newline at end of file diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..078c45e --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,951 @@ +{ + "name": "web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "react": "^19.3.0", + "react-dom": "^19.3.0", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "vite": "^8.3.2" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "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==", + "devOptional": true, + "license": "MIT", + "peer": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "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/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "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/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/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..322de32 --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "web", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "react": "^19.3.0", + "react-dom": "^19.3.0", + "zustand": "^5.0.15" + }, + "devDependencies": { + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "vite": "^8.3.2" + } +} diff --git a/web/src/App.jsx b/web/src/App.jsx index 83b5cac..41cd346 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,6 +1,3 @@ -import React, { useState, useEffect } from 'react'; -import { escapeHTML } from './App.utils'; - const state = { tenants: [], applications: [], @@ -36,7 +33,7 @@ async function api(path, options = {}) { const response = await fetch(path, { credentials: 'same-origin', ...options, - headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } + headers: {'Content-Type': 'application/json', ...(options.headers || {})} }); if (response.status === 401) { location.href = '/login'; @@ -139,7 +136,7 @@ $$('.tab').forEach(button => button.addEventListener('click', () => { $('#tenant-form').addEventListener('submit', async event => { event.preventDefault(); try { - await api('/api/v1/tenants', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); + await api('/api/v1/tenants', {method: 'POST', body: JSON.stringify(formJSON(event.target))}); event.target.closest('dialog').close(); event.target.reset(); notice('Tenant created'); @@ -152,7 +149,7 @@ $('#tenant-form').addEventListener('submit', async event => { $('#app-form').addEventListener('submit', async event => { event.preventDefault(); try { - await api('/api/v1/applications', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); + await api('/api/v1/applications', {method: 'POST', body: JSON.stringify(formJSON(event.target))}); event.target.closest('dialog').close(); event.target.reset(); notice('Application registered'); @@ -165,7 +162,7 @@ $('#app-form').addEventListener('submit', async event => { $('#db-form').addEventListener('submit', async event => { event.preventDefault(); try { - await api('/api/v1/databases', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); + await api('/api/v1/databases', {method: 'POST', body: JSON.stringify(formJSON(event.target))}); event.target.closest('dialog').close(); event.target.reset(); notice('Database connection added'); @@ -197,7 +194,7 @@ document.addEventListener('click', async event => { if (dbID) { if (confirm('Run migrations for this database?')) { try { - await api(`/api/v1/databases/${dbID}/migrations`, { method: 'POST' }); + await api(`/api/v1/databases/${dbID}/migrations`, {method: 'POST'}); notice('Migrations executed'); await load(); } catch (error) { diff --git a/web/src/main.jsx b/web/src/main.jsx deleted file mode 100644 index a46fe9c..0000000 --- a/web/src/main.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import './index.css'; -import { createRoot } from 'react-dom/client'; -import App from './App'; - -createRoot(document.getElementById('root')).render( - - - -); \ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..a5e486f --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,110 @@ +import { create } from 'zustand' +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +const root = document.getElementById('root') +if (!root) { + console.error('Root element not found') + process.exit(1) +} + +const client = ReactDOM.createRoot(root) + +// Define types for the dashboard data +interface DashboardData { + tenants: Array<{ id: string; name: string; role: string; slug: string }> + applications: Array<{ id: string; name: string; description: string; tenant_id: string; client_ready: boolean; client_id: string }> + databases: Array<{ id: string; name: string; connection_string: string; tenant_id: string; migration_status: string }> + is_system_administrator: boolean + selected_tenant: string +} + +interface State { + tenants: DashboardData['tenants'] + applications: DashboardData['applications'] + databases: DashboardData['databases'] + selectedTenant: string + isSystemAdministrator: boolean + setState: (partial: Partial) => void +} + +const useStore = create((set) => ({ + tenants: [], + applications: [], + databases: [], + selectedTenant: '', + isSystemAdministrator: false, + setState: (partial) => set(partial), +})) + +// Load dashboard data +fetch('/api/v1/dashboard') + .then((res) => { + if (!res.ok) throw new Error('API error: ' + res.status) + return res.json() + }) + .then((data: DashboardData) => { + useStore.getState().setState(data) + }) + .catch((err) => { + console.error('Failed to load dashboard:', err) + }) + +// Render the React app +const App = () => { + const { tenants, applications, databases, selectedTenant, isSystemAdministrator, setState } = useStore.getState() + + const render = () => { + const tenantCount = document.getElementById('tenant-count') + const appCount = document.getElementById('app-count') + const dbCount = document.getElementById('db-count') + + if (tenantCount) tenantCount.textContent = tenants.length + if (appCount) appCount.textContent = applications.length + if (dbCount) dbCount.textContent = databases.length + + const tenantList = document.getElementById('tenant-list') + if (tenantList) { + tenantList.innerHTML = tenants + .map( + (i) => + `
${i.role || ''}

${i.name || ''}

${i.slug || ''}

` + ) + .join('') || '

No tenants yet

' + } + + const appList = document.getElementById('app-list') + if (appList) { + const filteredApps = applications.filter( + (a) => a.tenant_id === selectedTenant + ) + appList.innerHTML = filteredApps + .map( + (i) => + `
${i.tenant_slug || ''}

${i.name || ''}

${i.description || 'No description'}

` + ) + .join('') || '

No applications yet

' + } + + const dbList = document.getElementById('db-list') + if (dbList) { + const filteredDBs = databases.filter((d) => d.tenant_id === selectedTenant) + dbList.innerHTML = filteredDBs + .map( + (i) => + `

${i.name || ''}

${i.connection_string ? 'Connected' : 'Not connected'}

` + ) + .join('') || '

No databases yet

' + } + } + + // Initial render + render() + + // Set up event listeners for any interactive elements + return null +} + +ReactDOM.createRoot(root).render() \ No newline at end of file diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..9c02530 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + base: '', + publicDir: 'src', +}) \ No newline at end of file