initial commit

This commit is contained in:
Daniel Noulet 2026-10-04 16:20:07 +02:00
commit ebd5bb09fd
30 changed files with 1920 additions and 0 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
out/
*.sql
*.db
data/
*.pem
*.key
*.cert
node_modules/
dist/
*.signame

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
out/
*.sql
*.db
data/
node_modules/
dist/
*.pem
*.key
*.cert

10
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

42
cmd/go-mad/main.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"

View file

@ -0,0 +1,7 @@
{{- define "go-mad.name" -}}
go-mad
{{- end -}}
{{- define "go-mad.fullname" -}}
{{- .Release.Name -}}
{{- end -}}

View 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

View 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
View 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
View 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"
}
}

View 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
View 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
View file

@ -0,0 +1 @@
export { escapeHTML };

585
web/src/index.css Normal file
View 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
View 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
View 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>
);