initial commit
This commit is contained in:
commit
ebd5bb09fd
30 changed files with 1920 additions and 0 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
out/
|
||||||
|
*.sql
|
||||||
|
*.db
|
||||||
|
data/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.cert
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.signame
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
out/
|
||||||
|
*.sql
|
||||||
|
*.db
|
||||||
|
data/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.cert
|
||||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -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
|
||||||
9
.idea/go-mad.iml
generated
Normal file
9
.idea/go-mad.iml
generated
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
10
.idea/go.imports.xml
generated
Normal file
10
.idea/go.imports.xml
generated
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GoImports">
|
||||||
|
<option name="excludedPackages">
|
||||||
|
<array>
|
||||||
|
<option value="golang.org/x/net/context" />
|
||||||
|
</array>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/go-mad.iml" filepath="$PROJECT_DIR$/.idea/go-mad.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
98
Makefile
Normal file
98
Makefile
Normal file
|
|
@ -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 <target>"
|
||||||
|
@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"
|
||||||
BIN
bin/go-mad
Executable file
BIN
bin/go-mad
Executable file
Binary file not shown.
42
cmd/go-mad/main.go
Normal file
42
cmd/go-mad/main.go
Normal file
|
|
@ -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...")
|
||||||
|
}
|
||||||
9
config.example.yaml
Normal file
9
config.example.yaml
Normal file
|
|
@ -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"
|
||||||
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
|
|
@ -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:
|
||||||
143
docs/README.md
Normal file
143
docs/README.md
Normal file
|
|
@ -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
|
||||||
96
docs/installation.md
Normal file
96
docs/installation.md
Normal file
|
|
@ -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
|
||||||
110
docs/operations.md
Normal file
110
docs/operations.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
20
go.mod
Normal file
20
go.mod
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
36
go.sum
Normal file
36
go.sum
Normal file
|
|
@ -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=
|
||||||
6
helm/go-mad/Chart.yaml
Normal file
6
helm/go-mad/Chart.yaml
Normal file
|
|
@ -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"
|
||||||
7
helm/go-mad/templates/_helpers.tpl
Normal file
7
helm/go-mad/templates/_helpers.tpl
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{{- define "go-mad.name" -}}
|
||||||
|
go-mad
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "go-mad.fullname" -}}
|
||||||
|
{{- .Release.Name -}}
|
||||||
|
{{- end -}}
|
||||||
52
helm/go-mad/templates/deployment.yaml
Normal file
52
helm/go-mad/templates/deployment.yaml
Normal file
|
|
@ -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
|
||||||
18
helm/go-mad/templates/service.yaml
Normal file
18
helm/go-mad/templates/service.yaml
Normal file
|
|
@ -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 }}
|
||||||
53
helm/go-mad/values.yaml
Normal file
53
helm/go-mad/values.yaml
Normal file
|
|
@ -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
|
||||||
89
internal/config/config.go
Normal file
89
internal/config/config.go
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
171
internal/database/database.go
Normal file
171
internal/database/database.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
245
web/src/App.jsx
Normal file
245
web/src/App.jsx
Normal file
|
|
@ -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 => `<option value="${escapeHTML(item.value)}">${escapeHTML(item.label)}</option>`)
|
||||||
|
.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 => `
|
||||||
|
<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>
|
||||||
|
${escapeHTML(item.name)}
|
||||||
|
</option>
|
||||||
|
`).join('') || '<option>No tenants yet</option>';
|
||||||
|
|
||||||
|
$('#tenant-list').innerHTML = state.tenants.map(item => `
|
||||||
|
<article class="card">
|
||||||
|
<span class="badge">${escapeHTML(item.role)}</span>
|
||||||
|
<h3>${escapeHTML(item.name)}</h3>
|
||||||
|
<p>${escapeHTML(item.slug)}</p>
|
||||||
|
<div class="meta"><span>Tenant</span><code>${escapeHTML(item.id.slice(0, 8))}</code></div>
|
||||||
|
</article>
|
||||||
|
`).join('') || empty('No tenants yet');
|
||||||
|
|
||||||
|
$('#app-list').innerHTML = tenantApps.map(item => `
|
||||||
|
<article class="card">
|
||||||
|
<span class="badge">${escapeHTML(item.tenant_slug)}</span>
|
||||||
|
<h3>${escapeHTML(item.name)}</h3>
|
||||||
|
<p>${escapeHTML(item.description || 'No description')}</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span>${item.client_ready ? 'Client login ready' : 'Client login not configured'}</span>
|
||||||
|
<code>${escapeHTML(item.client_id)}</code>
|
||||||
|
</div>
|
||||||
|
${canManageDatabases ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Manage databases</button>` : ''}
|
||||||
|
</article>
|
||||||
|
`).join('') || empty('No applications yet');
|
||||||
|
|
||||||
|
$('#db-list').innerHTML = tenantDBs.map(item => `
|
||||||
|
<article class="card">
|
||||||
|
<h3>${escapeHTML(item.name)}</h3>
|
||||||
|
<p>${escapeHTML(item.connection_string ? 'Connected' : 'Not connected')}</p>
|
||||||
|
<div class="meta">
|
||||||
|
<span>Tenant ID: ${escapeHTML(item.tenant_id)}</span>
|
||||||
|
${item.migration_status ? `<span>Migrations: ${escapeHTML(item.migration_status)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${canManageDatabases ? `<button class="card-action" data-db="${escapeHTML(item.id)}">Run migrations</button>` : ''}
|
||||||
|
</article>
|
||||||
|
`).join('') || empty('No databases yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty = text => `<article class="card"><p>${text}</p></article>`;
|
||||||
|
|
||||||
|
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 => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
|
||||||
|
select.value = tenantID;
|
||||||
|
});
|
||||||
|
|
||||||
|
$$('.app-select').forEach(select => select.innerHTML = state.applications.filter(a => a.tenant_id === tenantID).map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('') || '<option>No applications</option>');
|
||||||
|
|
||||||
|
$$('[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));
|
||||||
1
web/src/App.utils.js
Normal file
1
web/src/App.utils.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { escapeHTML };
|
||||||
585
web/src/index.css
Normal file
585
web/src/index.css
Normal file
|
|
@ -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%}
|
||||||
|
}
|
||||||
13
web/src/index.html
Normal file
13
web/src/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>go-mad - Tenant Database Manager</title>
|
||||||
|
<link rel="stylesheet" href="/static/index.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/static/index.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
web/src/main.jsx
Normal file
9
web/src/main.jsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import './index.css';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
Loading…
Add table
Add a link
Reference in a new issue