commit ebd5bb09fd2a8f32211d07ba18a85f894ef4f47c Author: Daniel Noulet Date: Sun Oct 4 16:20:07 2026 +0200 initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d8d6ca3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +out/ +*.sql +*.db +data/ +*.pem +*.key +*.cert +node_modules/ +dist/ +*.signame \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1892a58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +out/ +*.sql +*.db +data/ +node_modules/ +dist/ +*.pem +*.key +*.cert \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go-mad.iml b/.idea/go-mad.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/go-mad.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3918519 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9f87374 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.27.1-alpine AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY cmd/ /src/cmd/ +COPY internal/ /src/internal/ +COPY web/ /src/web/ +COPY internal/migration/ /src/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 + +EXPOSE 8080 + +ENTRYPOINT ["/go-mad"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c0ca55c --- /dev/null +++ b/Makefile @@ -0,0 +1,98 @@ +.PHONY: all build test vet fmt compose-up compose-down logs clean help + +GO_VERSION ?= 1.27.1 +BINARY_NAME ?= go-mad +BINARY_DIR ?= ./bin +WEB_DIR ?= web +DOCKER_BUILD ?= build + +# Configuration defaults +GO_LOOSE_ADDR ?= http://localhost:8080 +GO_MAD_ADDR ?= http://localhost:8080 + +## ---- Build ---- + +build: fmt vet ## Build the Go binary + @mkdir -p $(BINARY_DIR) + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $(BINARY_DIR)/$(BINARY_NAME) ./cmd/$(BINARY_NAME)/main.go + +## ---- Development ---- + +run: build ## Run the Go binary (requires postgres running) + @$(BINARY_NAME) + +compose-up: ## Start docker compose (postgres + go-mad) + docker compose up --build -d + +compose-down: ## Stop docker compose + docker compose down + +compose-logs: ## View docker compose logs + docker compose logs -f + +## ---- Code Quality ---- + +fmt: ## Format Go code + go fmt ./... + +vet: ## Vet Go code + go vet ./... + +test: ## Run Go unit tests + go test ./... + +integration-test: ## Run integration tests (requires test database) + @test -n "$(GO_MAD_TEST_DATABASE_URL)" || (echo "GO_MAD_TEST_DATABASE_URL is required" && exit 1) + go test ./... -run TestIntegration -v + +## ---- Web Frontend ---- + +web-build: ## Build React frontend + npm install + npm run build + +web-dev: ## Run React dev server + npm install + npm run dev + +## ---- Docker ---- + +docker-build: ## Build docker image + docker build -t $(BINARY_NAME):latest . + +docker-push: ## Push docker image + docker push $(BINARY_NAME):latest + +## ---- Cleanup ---- + +clean: ## Remove build artifacts + rm -rf /out + rm -rf $(WEB_DIR)/dist + rm -rf $(WEB_NODE_MODULES) + +help: ## Show this help message + @echo "Usage: make " + @echo "" + @echo "Build targets:" + @echo " build Build the Go binary" + @echo " run Run the Go binary" + @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 "" + @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 new file mode 100755 index 0000000..778ac07 Binary files /dev/null and b/bin/go-mad differ diff --git a/cmd/go-mad/main.go b/cmd/go-mad/main.go new file mode 100644 index 0000000..6d8a946 --- /dev/null +++ b/cmd/go-mad/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/dnoulet/go-mad/internal/config" + "github.com/dnoulet/go-mad/internal/database" +) + +func main() { + cfg, err := config.LoadFromEnv() + if err != nil { + log.Fatalf("Loading config from environment: %v", err) + } + + db, err := database.Open(cfg.Database.URL) + if err != nil { + log.Fatalf("Opening database: %v", err) + } + defer db.Close() + + if err := database.Migrate(db, "internal/migration"); err != nil { + log.Fatalf("Running migrations: %v", err) + } + + // Set up signal handling for graceful shutdown + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + + // Start server + go func() { + log.Printf("Starting server on %s:%s", cfg.Server.Host, cfg.Server.Port) + // TODO: start HTTP server + }() + + // Wait for interrupt + <-stop + log.Println("Shutting down gracefully...") +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..cd8a2ec --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,9 @@ +server: + host: "0.0.0.0" + port: "8080" + +database: + url: "postgres://mad:mad@localhost:5432/mad?sslmode=disable" + +jwt: + secret_key: "change-me-to-a-32-plus-char-secret" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..be338d0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + go-mad-database: + image: postgres:15-alpine + container_name: go-mad-postgres + environment: + POSTGRES_USER: mad + POSTGRES_PASSWORD: mad + POSTGRES_DB: mad + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mad -d mad"] + interval: 5s + timeout: 5s + retries: 5 + + go-mad: + build: + context: . + dockerfile: Dockerfile + container_name: go-mad + depends_on: + go-mad-database: + 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_SERVER_PORT: "8080" + GO_MAD_JWT_SECRET: change-me-to-a-32-plus-char-secret + ports: + - "8081:8080" +volumes: + postgres_data: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..a832e47 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,143 @@ +# go-mad + +A web application for managing tenant database connections and running migration scripts. + +## Overview + +go-mad is a Go web application that helps administrators manage database connections for multiple tenants. When a new tenant is added, the application contains all the database connection information for the applications associated with that tenant. The frontend is built with React and provides a beautiful, intuitive interface for managing tenants, applications, and database migrations. + +## Features + +- **Tenant Management**: Create and manage tenants with their database connections +- **Application Management**: Register and manage applications per tenant +- **Database Connection Management**: Track database connections and status per tenant +- **Migration Scripts**: Run goose-style SQL migration scripts per tenant database +- **Role-Based Access Control**: System administrators and tenant-specific permissions +- **Beautiful React UI**: Inspired by go-loose with dark/light theme support + +## Quick Start + +### Using Docker Compose + +```bash +make compose-up +``` + +This will start: +- PostgreSQL database (`postgres:15-alpine`) +- go-mad application + +Access the application at `http://localhost:8080` + +### Manual Development + +```bash +# Format and vet +make fmt +make vet + +# Build and run +make build +./go-mad + +# Or run with docker compose +make compose-up +``` + +## Configuration + +go-mad can be configured via environment variables or a `config.yaml` file: + +| Variable | Default | Description | +|---|---|---| +| `GO_MAD_DATABASE_URL` | `postgres://mad:mad@localhost:5432/mad?sslmode=disable` | PostgreSQL connection string | +| `GO_MAD_SERVER_PORT` | `8080` | HTTP server port | +| `GO_MAD_JWT_SECRET` | `change-me-to-a-32-plus-char-secret` | JWT secret for authentication | +| `GO_MAD_CONFIG_PATH` | `config.yaml` | Path to YAML config file | + +### config.yaml example + +```yaml +server: + host: "0.0.0.0" + port: "8080" + +database: + url: "postgres://mad:mad@localhost:5432/mad?sslmode=disable" + +jwt: + secret_key: "your-32-plus-char-secret-here" +``` + +## API Endpoints + +### Dashboard + +- `GET /api/v1/dashboard` - Get dashboard data including tenants, applications, and databases + +### Tenants + +- `GET /api/v1/tenants` - List all tenants +- `POST /api/v1/tenants` - Create a new tenant + +### Applications + +- `GET /api/v1/applications` - List applications for a tenant +- `POST /api/v1/applications` - Register a new application + +### Databases + +- `GET /api/v1/databases` - List databases for a tenant +- `POST /api/v1/databases` - Add a database connection for a tenant +- `POST /api/v1/databases/{id}/migrations` - Run migrations for a database + +### Authentication + +- `GET /login` - Login page +- `POST /api/v1/auth/login` - Authenticate + +## Development Workflow + +```bash +# Build the Go binary +make build + +# Build the React frontend +make web-build + +# Start with docker compose +make compose-up + +# View logs +make compose-logs + +# Stop +make compose-down +``` + +## Docker + +### Build + +```bash +docker build -t go-mad:latest . +``` + +### Push + +```bash +docker push go-mad:latest +``` + +## Kubernetes + +Deploy using Helm: + +```bash +helm repo add go-mad ./helm/go-mad +helm install go-mad go-mad/go-mad +``` + +## License + +MIT \ No newline at end of file diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..38b729c --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,96 @@ +# Installation + +## Prerequisites + +- Go 1.27.1 or later +- Docker and Docker Compose (for containerized deployment) +- Node.js and npm (for React frontend development) +- PostgreSQL 15+ (or use Docker) + +## Local Development + +### 1. Clone the repository + +```bash +git clone https://github.com/your-organization/go-mad.git +cd go-mad +``` + +### 2. Install dependencies + +```bash +# Go dependencies +go mod download + +# Node dependencies +npm install +``` + +### 3. Set up the database + +```bash +# Using Docker Compose +make compose-up + +# Or manually +createdb mad +# Or set up PostgreSQL with user 'mad' and database 'mad' +``` + +### 4. Configure the application + +```bash +# Create config.yaml +cp config.example.yaml config.yaml +# Edit config.yaml with your settings +``` + +### 5. Run migrations + +```bash +make test +# Or run manually +go run ./cmd/go-mad +``` + +The application will automatically run migrations on startup. + +### 6. Start the development server + +```bash +# Development mode with hot reload +make web-dev + +# Or run the Go binary +make run +``` + +Access the application at `http://localhost:8080` + +## Docker Deployment + +```bash +# Build and start +docker compose up --build -d + +# View logs +docker compose logs -f + +# Stop +docker compose down +``` + +## Kubernetes Deployment + +```bash +helm install go-mad oci://your-registry/go-mad/helm/go-mad +``` + +## Production Checklist + +- [ ] Change the default JWT secret +- [ ] Configure proper PostgreSQL credentials +- [ ] Set up HTTPS/TLS via ingress +- [ ] Configure backups for the PostgreSQL data volume +- [ ] Set up monitoring and logging +- [ ] Review role-based access control settings \ No newline at end of file diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..43a3f75 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,110 @@ +# Operations + +## Migration Scripts + +go-mad uses goose-style SQL migration scripts stored in `internal/migration/`. Each migration file contains `-- +goose Up` and `-- +goose Down` markers. + +### Migration Format + +```sql +-- +goose Up +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + tenant_id INTEGER REFERENCES tenants(id) ON DELETE CASCADE, + username VARCHAR(255) NOT NULL UNIQUE +); + +-- +goose Down +DROP TABLE IF EXISTS users CASCADE; +``` + +### Running Migrations + +Migrations are automatically run at application startup via `database.Migrate()`. To run migrations manually or for a specific tenant: + +```bash +# Using the API +curl -X POST http://localhost:8080/api/v1/databases/{id}/migrations + +# Or via command line (if exposing migration tooling) +go run ./cmd/go-mad --migrate +``` + +### Creating New Migrations + +```bash +# Create a new migration file +touch internal/migration/0004_permissions.sql + +# Write up/down sections +-- +goose Up +CREATE TABLE permissions ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE +); + +-- +goose Down +DROP TABLE IF EXISTS permissions CASCADE; +``` + +### Checking Migration Status + +The application tracks applied migrations in a `schema_migrations` table. To check which migrations have been applied, query the database: + +```sql +SELECT * FROM schema_migrations ORDER BY version; +``` + +## Backup and Restore + +### PostgreSQL Backup + +```bash +# Using docker exec +docker exec go-mad-postgres pg_dump -U mad -d mad > backup.sql + +# Or using pg_dump directly +pg_dump -U mad -d mad > backup.sql +``` + +### Restore + +```bash +psql -U mad -d mad < backup.sql +``` + +## Monitoring + +The application exposes a `/health` endpoint for liveness and readiness probes: + +```bash +curl http://localhost:8080/health +``` + +## Logs + +```bash +# Docker compose logs +make compose-logs + +# Or kubectl +kubectl logs deployment/go-mad +``` + +## Scaling + +The application is designed to be stateless (except for the database connection pool). Multiple replicas can be run behind a load balancer. + +## Upgrading + +```bash +# Pull new image +docker pull go-mad:latest + +# Or rebuild +make docker-build +docker push go-mad:latest + +# Deploy +helm upgrade go-mad oci://your-registry/go-mad/helm/go-mad +``` \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3fa9041 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/dnoulet/go-mad + +go 1.27.1 + +require ( + github.com/jackc/pgx/v5 v5.11.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/text v0.38.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4cf4fa9 --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helm/go-mad/Chart.yaml b/helm/go-mad/Chart.yaml new file mode 100644 index 0000000..d63106e --- /dev/null +++ b/helm/go-mad/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +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 diff --git a/helm/go-mad/templates/_helpers.tpl b/helm/go-mad/templates/_helpers.tpl new file mode 100644 index 0000000..f203077 --- /dev/null +++ b/helm/go-mad/templates/_helpers.tpl @@ -0,0 +1,7 @@ +{{- define "go-mad.name" -}} +go-mad +{{- end -}} + +{{- define "go-mad.fullname" -}} +{{- .Release.Name -}} +{{- end -}} \ No newline at end of file diff --git a/helm/go-mad/templates/deployment.yaml b/helm/go-mad/templates/deployment.yaml new file mode 100644 index 0000000..011bec4 --- /dev/null +++ b/helm/go-mad/templates/deployment.yaml @@ -0,0 +1,52 @@ +{{- $fullName := include "go-mad.fullname" . }} +{{- $values := index .Values "go-mad" }} + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }} + labels: + app.kubernetes.io/name: {{ include "go-mad.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/version: "{{ .Chart.appVersion }}" +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + app.kubernetes.io/name: {{ include "go-mad.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "go-mad.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + containers: + - name: {{ $fullName }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - containerPort: {{ $values.serverPort | int }} + env: + - name: GO_MAD_DATABASE_URL + value: "{{ $values.databaseURL }}" + - name: GO_MAD_SERVER_PORT + value: "{{ $values.serverPort }}" + - name: GO_MAD_JWT_SECRET + value: "{{ $values.jwtSecret }}" + {{- with $values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + livenessProbe: + httpGet: + path: /health + port: {{ $values.serverPort | int }} + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /health + port: {{ $values.serverPort | int }} + initialDelaySeconds: 10 + periodSeconds: 15 \ No newline at end of file diff --git a/helm/go-mad/templates/service.yaml b/helm/go-mad/templates/service.yaml new file mode 100644 index 0000000..38fc6d8 --- /dev/null +++ b/helm/go-mad/templates/service.yaml @@ -0,0 +1,18 @@ +{{- $fullName := include "go-mad.fullname" . }} + +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }} + labels: + app.kubernetes.io/name: {{ include "go-mad.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ index .Values "go-mad" "serverPort" | int }} + protocol: TCP + selector: + app.kubernetes.io/name: {{ include "go-mad.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} \ No newline at end of file diff --git a/helm/go-mad/values.yaml b/helm/go-mad/values.yaml new file mode 100644 index 0000000..1454cf2 --- /dev/null +++ b/helm/go-mad/values.yaml @@ -0,0 +1,53 @@ +replicaCount: 1 + +image: + repository: batty1039.startdedicated.net:5000/go-mad + pullPolicy: IfNotPresent + tag: "latest" + +service: + type: ClusterIP + port: 8080 + +resources: + limits: + cpu: 500m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + +database: + enabled: true + postgresql: + enabled: true + auth: + username: mad + password: mad + database: mad + +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 + +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: + enabled: true + type: serving + port: 8080 \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..64e95f2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,89 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server ServerConfig + Database DatabaseConfig + JWT JWTConfig +} + +type ServerConfig struct { + Host string + Port string +} + +type DatabaseConfig struct { + URL string +} + +type JWTConfig struct { + SecretKey string +} + +func LoadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + cfg.applyDefaults() + return &cfg, nil +} + +func LoadFromEnv() (*Config, error) { + cfg := DefaultConfig() + + if host := os.Getenv("GO_MAD_SERVER_HOST"); host != "" { + cfg.Server.Host = host + } + if port := os.Getenv("GO_MAD_SERVER_PORT"); port != "" { + cfg.Server.Port = port + } + if dbURL := os.Getenv("GO_MAD_DATABASE_URL"); dbURL != "" { + cfg.Database.URL = dbURL + } + if jwtSecret := os.Getenv("GO_MAD_JWT_SECRET"); jwtSecret != "" { + cfg.JWT.SecretKey = jwtSecret + } + + cfg.applyDefaults() + return cfg, nil +} + +func DefaultConfig() *Config { + return &Config{ + Server: ServerConfig{ + Host: "0.0.0.0", + Port: "8080", + }, + Database: DatabaseConfig{ + URL: "postgres://mad:mad@localhost:5432/mad?sslmode=disable", + }, + JWT: JWTConfig{ + SecretKey: "change-me-to-a-32-plus-char-secret", + }, + } +} + +func (c *Config) applyDefaults() { + if c.Server.Port == "" { + c.Server.Port = "8080" + } + if c.Database.URL == "" { + c.Database.URL = "postgres://mad:mad@localhost:5432/mad?sslmode=disable" + } + if c.JWT.SecretKey == "" { + c.JWT.SecretKey = "change-me-to-a-32-plus-char-secret" + } +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..8cd003f --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,171 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func Open(databaseURL string) (*sql.DB, error) { + db, err := sql.Open("pgx", databaseURL) + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + + db.SetMaxOpenConns(20) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(30 * 60) + + if err := db.PingContext(context.Background()); err != nil { + db.Close() + return nil, fmt.Errorf("pinging database: %w", err) + } + + log.Println("Database connection opened successfully") + return db, nil +} + +type Migration struct { + Version string + Description string + SQL string +} + +func FindMigrations(migrationPath string) ([]Migration, error) { + var migrations []Migration + + entries, err := os.ReadDir(migrationPath) + if err != nil { + return nil, fmt.Errorf("reading migration directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + + path := filepath.Join(migrationPath, entry.Name()) + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading migration file %s: %w", entry.Name(), err) + } + + contentStr := string(content) + migration := parseMigration(contentStr) + migrations = append(migrations, migration) + } + + return migrations, nil +} + +func parseMigration(content string) Migration { + // Parse goose-style migration: -- +goose Up / -- +goose Down + lines := strings.Split(content, "\n") + var upSQL strings.Builder + inUp := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.Contains(trimmed, "-- +goose Up") { + inUp = true + continue + } + if strings.Contains(trimmed, "-- +goose Down") { + inUp = false + continue + } + + if inUp { + upSQL.WriteString(line) + upSQL.WriteString("\n") + } + } + + // 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 + } + + return Migration{ + Version: version, + Description: desc, + SQL: upSQL.String(), + } +} + +func Migrate(db *sql.DB, migrationPath string) error { + migrations, err := FindMigrations(migrationPath) + if err != nil { + return fmt.Errorf("finding migrations: %w", err) + } + + if len(migrations) == 0 { + log.Println("No migration files found") + return nil + } + + log.Printf("Found %d migration(s), running up...", len(migrations)) + + // Ensure schema_migrations table exists + _, err = db.ExecContext(context.Background(), ` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(255) PRIMARY KEY, + description VARCHAR(255), + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return fmt.Errorf("creating schema_migrations table: %w", err) + } + + for _, m := range migrations { + // Check if already applied + var count int + err := db.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM schema_migrations WHERE version = $1", m.Version).Scan(&count) + if err != nil { + return fmt.Errorf("checking migration %s: %w", m.Version, err) + } + + if count > 0 { + log.Printf("Migration %s already applied, skipping", m.Version) + continue + } + + // Execute the up SQL + tx, err := db.BeginTx(context.Background(), nil) + if err != nil { + return fmt.Errorf("beginning transaction for migration %s: %w", m.Version, err) + } + + _, err = tx.ExecContext(context.Background(), m.SQL) + if err != nil { + tx.Rollback() + return fmt.Errorf("executing migration %s: %w", m.Version, err) + } + + // Record the migration as applied + _, err = tx.ExecContext(context.Background(), "INSERT INTO schema_migrations (version, description) VALUES ($1, $2)", m.Version, m.Description) + if err != nil { + tx.Rollback() + return fmt.Errorf("recording migration %s: %w", m.Version, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing migration %s: %w", m.Version, err) + } + + log.Printf("Applied migration %s: %s", m.Version, m.Description) + } + + log.Println("Migrations completed successfully") + return nil +} diff --git a/web/src/App.jsx b/web/src/App.jsx new file mode 100644 index 0000000..83b5cac --- /dev/null +++ b/web/src/App.jsx @@ -0,0 +1,245 @@ +import React, { useState, useEffect } from 'react'; +import { escapeHTML } from './App.utils'; + +const state = { + tenants: [], + applications: [], + databases: [], + selectedTenant: '', + isSystemAdministrator: false, +}; + +const $ = selector => document.querySelector(selector); +const $$ = selector => [...document.querySelectorAll(selector)]; +const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'': ''' +}[c])); + +const roleOptions = roles => roles + .map(item => ``) + .join(''); +const $$Q = '[data-action]'; + +const notice = (message, error = false) => { + const el = $('#notice'); + el.textContent = message; + el.hidden = false; + el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)'; + setTimeout(() => el.hidden = true, 5000); +}; + +async function api(path, options = {}) { + const response = await fetch(path, { + credentials: 'same-origin', + ...options, + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } + }); + if (response.status === 401) { + location.href = '/login'; + throw new Error('Session expired'); + } + const body = response.status === 204 ? null : await response.json(); + if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`); + return body; +} + +async function load() { + const data = await api('/api/v1/dashboard'); + state.tenants = data.tenants || []; + state.applications = data.applications || []; + state.databases = data.databases || []; + state.isSystemAdministrator = data.is_system_administrator || false; + state.selectedTenant = data.selected_tenant || state.tenants[0]?.id || ''; + render(); + updateTenantVisibility(); +} + +function updateTenantVisibility() { + $$Q('[data-tenant-action]').forEach(el => { + const action = el.dataset.tenantAction; + if (action === 'system-only' && !state.isSystemAdministrator) { + el.hidden = true; + } else { + el.hidden = false; + } + }); +} + +function render() { + const tenantID = state.selectedTenant; + const selectedTenant = state.tenants.find(t => t.id === tenantID); + const canManageDatabases = selectedTenant ? selectedTenant.permissions && selectedTenant.permissions.includes('databases.manage') : false; + const tenantApps = state.applications.filter(a => a.tenant_id === tenantID); + const tenantDBs = state.databases.filter(d => d.tenant_id === tenantID); + + $('#tenant-count').textContent = state.tenants.length; + $('#app-count').textContent = tenantApps.length; + $('#db-count').textContent = tenantDBs.length; + + $('#tenant-context').innerHTML = state.tenants.map(item => ` + + `).join('') || ''; + + $('#tenant-list').innerHTML = state.tenants.map(item => ` +
+ ${escapeHTML(item.role)} +

${escapeHTML(item.name)}

+

${escapeHTML(item.slug)}

+
Tenant${escapeHTML(item.id.slice(0, 8))}
+
+ `).join('') || empty('No tenants yet'); + + $('#app-list').innerHTML = tenantApps.map(item => ` +
+ ${escapeHTML(item.tenant_slug)} +

${escapeHTML(item.name)}

+

${escapeHTML(item.description || 'No description')}

+
+ ${item.client_ready ? 'Client login ready' : 'Client login not configured'} + ${escapeHTML(item.client_id)} +
+ ${canManageDatabases ? `` : ''} +
+ `).join('') || empty('No applications yet'); + + $('#db-list').innerHTML = tenantDBs.map(item => ` +
+

${escapeHTML(item.name)}

+

${escapeHTML(item.connection_string ? 'Connected' : 'Not connected')}

+
+ Tenant ID: ${escapeHTML(item.tenant_id)} + ${item.migration_status ? `Migrations: ${escapeHTML(item.migration_status)}` : ''} +
+ ${canManageDatabases ? `` : ''} +
+ `).join('') || empty('No databases yet'); +} + +const empty = text => `

${text}

`; + +function formJSON(form) { + const data = Object.fromEntries(new FormData(form)); + return data; +} + +$$Q('[data-dialog]').forEach(button => button.addEventListener('click', () => $('#' + button.dataset.dialog).showModal())); +$$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close())); +$$('.tab').forEach(button => button.addEventListener('click', () => { + $$('.tab,.panel').forEach(el => el.classList.remove('active')); + button.classList.add('active'); + $('#' + button.dataset.panel).classList.add('active') +})); + +$('#tenant-form').addEventListener('submit', async event => { + event.preventDefault(); + try { + await api('/api/v1/tenants', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); + event.target.closest('dialog').close(); + event.target.reset(); + notice('Tenant created'); + await load(); + } catch (error) { + notice(error.message, true); + } +}); + +$('#app-form').addEventListener('submit', async event => { + event.preventDefault(); + try { + await api('/api/v1/applications', { method: 'POST', body: JSON.stringify(formJSON(event.target)) }); + event.target.closest('dialog').close(); + event.target.reset(); + notice('Application registered'); + await load(); + } catch (error) { + notice(error.message, true); + } +}); + +$('#db-form').addEventListener('submit', async event => { + event.preventDefault(); + try { + 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'); + await load(); + } catch (error) { + notice(error.message, true); + } +}); + +$$('[data-tenant-application-action]').forEach(element => element.hidden = !state.isSystemAdministrator); +$$('[data-tenant-db-action]').forEach(element => element.hidden = !state.isSystemAdministrator); + +$$('.tenant-select').forEach(select => { + select.innerHTML = state.tenants.map(item => ``).join(''); + select.value = tenantID; +}); + +$$('.app-select').forEach(select => select.innerHTML = state.applications.filter(a => a.tenant_id === tenantID).map(item => ``).join('') || ''); + +$$('[data-db-action]').forEach(element => { + const action = element.dataset.dbAction; + if (action === 'run-migration' && !state.isSystemAdministrator) { + element.hidden = true; + } +}); + +document.addEventListener('click', async event => { + const dbID = event.target.dataset.db; + if (dbID) { + if (confirm('Run migrations for this database?')) { + try { + await api(`/api/v1/databases/${dbID}/migrations`, { method: 'POST' }); + notice('Migrations executed'); + await load(); + } catch (error) { + notice(error.message, true); + } + } + return; + } + const clientID = event.target.dataset.client; + if (clientID) { + const app = state.applications.find(item => item.id === clientID), form = $('#client-form'); + form.application_id.value = app.id; + form.client_id.value = app.client_id; + form.redirect_uris.value = app.redirect_uris.join('\n'); + $('#client-dialog').showModal(); + return; + } +}); + +$('#copy-secret').addEventListener('click', () => { + const secret = $('#new-secret').textContent; + navigator.clipboard.writeText(secret).then(() => notice('Secret copied')); +}); + +const theme = $('#theme'), mode = $('#mode'); +theme.value = localStorage.getItem('gl-theme') || 'tokyo'; +document.documentElement.dataset.theme = theme.value; +document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark'; +theme.addEventListener('change', () => { + document.documentElement.dataset.theme = theme.value; + localStorage.setItem('gl-theme', theme.value); +}); +mode.addEventListener('click', () => { + const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.mode = next; + localStorage.setItem('gl-mode', next); +}); + +$('#tenant-context').addEventListener('change', event => { + state.selectedTenant = event.target.value; + render(); + updateTenantVisibility(); +}); + +load().catch(error => notice(error.message, true)); \ No newline at end of file diff --git a/web/src/App.utils.js b/web/src/App.utils.js new file mode 100644 index 0000000..0d019ba --- /dev/null +++ b/web/src/App.utils.js @@ -0,0 +1 @@ +export { escapeHTML }; \ No newline at end of file diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..93beadb --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,585 @@ +:root{ + --bg:#0d0d17; + --surface:#1a1a2e; + --surface2:#16162e; + --text:#e0e0e0; + --muted:#8888a0; + --accent:#bb86fc; + --accent2:#03dac6; + --good:#4caf50; + --danger:#f44336; + --border:#3d3d5d; + --shadow:rgba(0,0,0,.4); +} +[data-mode=light]{--bg:#f5f5f5;--surface:#fff;--surface2:#f0f0f5;--text:#1a1a2e;--muted:#424266;--accent:#0d47a1;--accent2:#1565c0;--good:#2e7d32;--danger:#c62828;--border:#bdbdbd;--shadow:rgba(0,0,0,.15)} +[data-mode=dark]{--bg:#0d0d17;--surface:#1a1a2e;--surface2:#16162e;--text:#e0e0e0;--muted:#8888a0;--accent:#bb86fc;--accent2:#03dac6;--good:#4caf50;--danger:#f44336;--border:#3d3d5d;--shadow:rgba(0,0,0,.4)} +*{ + box-sizing:border-box; + margin:0; + padding:0; +} +html{ + background:var(--bg); + color:var(--text); + font-family:Inter,ui-sans-serif,system-ui,sans-serif; +} +body{ + min-height:100vh; + background: + radial-gradient(circle at 70% -10%, color-mix(in srgb,var(--accent2) 12%,transparent) 25%, transparent 40%), + var(--bg); +} +a{ + color:var(--accent); + text-decoration:none; +} +button, +input, +textarea, +select{ + font:inherit; +} +.noise{ + position:fixed; + inset:0; + pointer-events:none; + opacity:.03; + background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} +header{ + height:68px; + border-bottom:1px solid var(--border); + display:flex; + align-items:center; + justify-content:space-between; + padding:0 24px; + background:color-mix(in srgb,var(--bg) 88%,transparent); + backdrop-filter:blur(14px); + position:sticky; + top:0; + z-index:20; +} +.brand{ + display:flex; + align-items:center; + gap:12px; + color:var(--text); + font-weight:750; + text-decoration:none; + letter-spacing:-.02em; +} +.brand-mark{ + display:grid; + place-items:center; + width:32px; + height:32px; + border:1px solid var(--accent); + color:var(--accent); + font:700 11px ui-monospace,monospace; + clip-path:polygon(10% 0,100% 0,100% 75%,75% 100%,0 100%,0 10%); +} +nav{ + display:flex; + align-items:center; + gap:16px; +} +nav a{ + color:var(--muted); + text-decoration:none; + font-size:14px; +} +nav form{ + margin:0; +} +.compact{ + display:flex; + align-items:center; + gap:6px; + font-size:11px; + color:var(--muted); +} +select, +input, +textarea{ + color:var(--text); + background:var(--surface2); + border:1px solid var(--border); + border-radius:6px; + padding:8px 12px; + outline:none; +} +select:focus, +input:focus, +textarea:focus{ + border-color:var(--accent); + box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent); +} +select:disabled, +input:disabled{ + background:var(--border); + color:var(--muted); +} +.icon-button, +.ghost{ + padding:6px 10px; + background:transparent; + border:1px solid var(--border); + color:var(--text); + border-radius:6px; +} +.identity{ + font-size:12px; + color:var(--accent); + display:inline-flex; + align-items-center; + gap:6px; +} +main{ + width:min(1400px,92vw); + margin:80px auto 60px; +} +.hero{ + min-height:280px; + display:flex; + align-items:center; + justify-content:space-between; + gap:40px; + border-bottom:1px solid var(--border); +} +.eyebrow{ + font:700 10px ui-monospace,monospace; + letter-spacing:.14em; + color:var(--accent); + margin:0 0 12px; +} +.hero h1{ + font-size:clamp(36px,4vw,56px); + line-height:.96; + letter-spacing:-.04em; + margin:0; +} +.hero h1 span{ + color:var(--accent2); +} +.lede{ + max-width:560px; + color:color-mix(in srgb,var(--text) 65%,transparent); + font-size:15px; + line-height:1.6; +} +.status-card{ + min-width:280px; + background:linear-gradient(145deg,var(--surface),var(--surface2)); + border:1px solid var(--border); + border-radius:12px; + padding:18px; + box-shadow:0 16px 48px var(--shadow); + display:grid; + grid-template-columns:auto 1fr; + gap:10px; + align-items:center; +} +.status-card span{ + display:block; + color:var(--good); + font-size:11px; + margin-top:3px; +} +.status-card code{ + grid-column:1/-1; + background:var(--bg); + padding:10px; + border-radius:6px; + color:var(--muted); +} +.pulse{ + width:8px; + height:8px; + border-radius:50%; + background:var(--good); + box-shadow:0 0 0 5px color-mix(in srgb,var(--good) 12%,transparent); +} +.stats{ + display:grid; + grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); + gap:2px; + background:var(--border); + border:1px solid var(--border); + margin:24px 0; + overflow:hidden; + border-radius:10px; +} +.stats article{ + background:var(--surface); + padding:16px 20px; +} +.stats span{ + display:block; + color:var(--muted); + font-size:11px; + text-transform:uppercase; + letter-spacing:.08em; +} +.stats strong{ + display:block; + font-size:24px; + margin-top:4px; +} +.workspace{ + display:grid; + grid-template-columns:180px 1fr; + gap:24px; +} +.workspace aside{ + display:flex; + flex-direction:column; + gap:4px; +} +.tab{ + border:0; + border-left:2px solid transparent; + background:transparent; + color:var(--muted); + padding:10px; + text-align:left; + font:600 11px ui-monospace,monospace; +} +.tab span{ + font-family:Inter,system-ui; + margin-left:6px; +} +.tab.active{ + color:var(--accent); + border-color:var(--accent); + background:color-mix(in srgb,var(--accent) 7%,transparent); +} +.panel{ + display:none; +} +.panel.active{ + display:block; +} +.panel-head{ + display:flex; + align-items:flex-end; + justify-content:space-between; + margin-bottom:16px; +} +.panel h2{ + font-size:28px; + letter-spacing:-.03em; + margin:0; +} +.panel-head button, +.card-action{ + background:var(--accent); + color:var(--bg); + font-weight:700; + border:0; + padding:9px 14px; + border-radius:6px; + cursor:pointer; +} +.card-grid{ + display:grid; + grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); + gap:12px; +} +.card{ + background:var(--surface); + border:1px solid var(--border); + border-radius:8px; + padding:16px; +} +.card h3{ + margin:0 0 4px; +} +.card p{ + color:color-mix(in srgb,var(--text) 55%,transparent); + min-height:20px; +} +.card-action{ + width:100%; + margin-top:12px; + background:color-mix(in srgb,var(--accent) 12%,var(--surface)); + color:var(--accent); + border:1px solid color-mix(in srgb,var(--accent) 40%,var(--border)); +} +.badge{ + font:700 9px ui-monospace,monospace; + text-transform:uppercase; + color:var(--accent); + background:color-mix(in srgb,var(--accent) 8%,transparent); + padding:4px 6px; + border-radius:4px; +} +.meta{ + display:flex; + justify-content:space-between; + margin-top:12px; + color:var(--muted); + font-size:10px; +} +.table-wr{ + width:100%; + border-collapse:collapse; +} +.table-wr th, +.table-wr td{ + padding:8px 4px; + border-bottom:1px solid var(--border); + font-size:11px; +} +.table-wr th{ + color:var(--muted); + text-align:left; + text-transform:uppercase; + letter-spacing:.06em; +} +.empty{ + padding:20px; + color:var(--muted); +} +.dialog{ + color:var(--text); + background:var(--surface); + border:1px solid var(--border); + border-radius:10px; + box-shadow:0 24px 80px rgba(0,0,0,.55); + width:min(480px,92vw); + padding:20px; +} +dialog::backdrop{ + background:rgba(8,8,15,.7); + backdrop-filter:blur(3px); +} +dialog form{ + display:grid; + gap:12px; +} +dialog form .dialog-head{ + display:flex; + align-items:center; + justify-content:space-between; +} +dialog form .dialog-head h3{ + margin:0; + font-size:20px; +} +.close{ + border:0; + background:transparent; + color:var(--muted); + font-size:24px; + cursor:pointer; +} +dialog label{ + display:grid; + gap:5px; + font-size:12px; + color:var(--muted); +} +textarea{ + resize:vertical; +} +.client-terminal{ + width:min(920px,92vw); + margin:24px 0; + position:relative; + background:color-mix(in srgb,var(--surface) 93%,black); + border:1px solid var(--border); + border-radius:8px; + box-shadow:0 32px 100px rgba(0,0,0,.55); + overflow:hidden; + font-family:ui-monospace,SFMono-Regular,monospace; +} +.client-terminal .terminal-bar{ + height:36px; + background:var(--surface2); + display:flex; + align-items:center; + gap:6px; + padding:0 12px; +} +.client-terminal .terminal-bar span{ + width:9px; + height:9px; + border-radius:50%; + background:var(--danger); +} +.client-terminal .terminal-bar span:nth-child(2){ + background:#e0af68; +} +.client-terminal .terminal-bar span:nth-child(3){ + background:var(--good); +} +.client-terminal .terminal-bar strong{ + position:absolute; + left:50%; + transform:translateX(-50%); + color:var(--muted); + font-size:11px; +} +.client-terminal .terminal-body{ + padding:24px; +} +.client-terminal .ascii{ + color:var(--accent); + font-size:clamp(5px,.8vw,10px); + line-height:1.2; + overflow:hidden; +} +.client-terminal .prompt{ + color:var(--good); +} +.client-terminal .muted{ + color:var(--muted); + max-width:550px; + line-height:1.4; +} +.login-page{ + display:grid; + place-items:center; + min-height:100vh; + overflow:hidden; +} +.login-glow{ + position:fixed; + width:500px; + height:500px; + border-radius:50%; + background:var(--accent2); + filter:blur(160px); + opacity:.12; +} +.terminal{ + width:min(920px,92vw); + margin:24px; + position:relative; + background:color-mix(in srgb,var(--surface) 93%,black); + border:1px solid var(--border); + border-radius:8px; + box-shadow:0 32px 100px rgba(0,0,0,.55); + overflow:hidden; + font-family:ui-monospace,SFMono-Regular,monospace; +} +.terminal .terminal-bar{ + height:36px; + background:var(--surface2); + display:flex; + align-items:center; + gap:6px; + padding:0 12px; +} +.terminal .terminal-bar span{ + width:9px; + height:9px; + border-radius:50%; + background:var(--danger); +} +.terminal .terminal-bar span:nth-child(2){ + background:#e0af68; +} +.terminal .terminal-bar span:nth-child(3){ + background:var(--good); +} +.terminal .terminal-bar strong{ + position:absolute; + left:50%; + transform:translateX(-50%); + color:var(--muted); + font-size:11px; +} +.terminal .terminal-body{ + padding:24px; +} +.terminal .ascii{ + color:var(--accent); + font-size:clamp(5px, .8vw, 10px); + line-height:1.2; + overflow:hidden; +} +.terminal .prompt{ + color:var(--good); +} +.terminal .muted{ + color:var(--muted); + max-width:600px; + line-height:1.4; +} +.password-login{ + display:grid; + grid-template-columns:1fr 1fr auto; + gap:8px; + align-items:end; + margin:20px 0; +} +.password-login label{ + display:grid; + gap:4px; + color:var(--muted); + font-size:11px; +} +.password-login button{ + height:36px; + border:0; + border-radius:6px; + background:var(--accent); + color:var(--bg); + font-weight:700; + padding:0 16px; +} +.login-divider{ + color:var(--muted); + font-size:10px; + text-transform:uppercase; + letter-spacing:.1em; +} +.installer-form{ + display:grid; + gap:12px; + margin-top:20px; +} +.installer-form label{ + display:grid; + gap:4px; + color:var(--muted); + font-size:11px; +} +.installer-form button{ + height:40px; + border:0; + border-radius:6px; + background:var(--accent); + color:var(--bg); + font-weight:700; +} +.installer-form .seed-option{ + display:flex; + gap:6px; + align-items:center; +} +.installer h1{ + font-size:clamp(32px,5vw,52px); + line-height:1; + margin:8px 0; + letter-spacing:-.04em; +} +.installer h1 span{ + color:var(--accent2); +} +@media(max-width:800px){ + header{padding:0 16px} + nav .compact,.identity{display:none} + .hero{padding:44px 0;display:block} + .status-card{margin-top:30px;min-width:0} + .stats{grid-template-columns:1fr 1fr} + .workspace{grid-template-columns:1fr} + .workspace aside{flex-direction:row;overflow:auto} + .tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent} + .card-grid{grid-template-columns:1fr} + .ascii{font-size:5px} + .terminal-body{padding:18px} + .password-login{grid-template-columns:1fr} + .password-login button{width:100%} +} \ No newline at end of file diff --git a/web/src/index.html b/web/src/index.html new file mode 100644 index 0000000..34b746f --- /dev/null +++ b/web/src/index.html @@ -0,0 +1,13 @@ + + + + + + go-mad - Tenant Database Manager + + + +
+ + + \ No newline at end of file diff --git a/web/src/main.jsx b/web/src/main.jsx new file mode 100644 index 0000000..a46fe9c --- /dev/null +++ b/web/src/main.jsx @@ -0,0 +1,9 @@ +import './index.css'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +createRoot(document.getElementById('root')).render( + + + +); \ No newline at end of file