Consolidate backend: drop vendored APwhy, converge on goose + sqlc

Phase 0 cleanup verified end-to-end:

- Delete vendored APwhy runtime (cmd/apwhy, internal/apwhy) and the
  unwired copy (internal/api/server.go + static bundle + cloned
  internal/{storage,analytics,auth,rbac}) — ~7.5k lines of dead code.
- Keep Containr's native internal/gateway; move APwhy management
  routes under /api/v1/gateway/* so they stop colliding with the
  native /services paths. Fix handlers against the real schema:
  UUID ids via RETURNING, integer enabled flags, plan validation,
  service validate endpoint, unique-violation mapping.
- Port agents.go off GORM onto sqlc (sqlc/queries/agents.sql);
  drop gorm dependencies; fix sqlc schema drift (services.environment_id).
- Replace the SELECT 1 goose baseline with the real consolidated
  schema (52 tables) minus APwhy's parallel auth/RBAC objects;
  idempotent via IF NOT EXISTS / OR REPLACE / guarded DO blocks so it
  replays cleanly over legacy installs. Add a drop migration for the
  dead objects and delete the legacy filename-tracked migrations/
  loader, directory, and Dockerfile copy. Verified via cmd/migrate:
  fresh install 53 tables, legacy upgrade clean to 20260917000000.
- Canvas: add invisible React Flow handles to ServiceNode so inferred
  auto-connections stop emitting warning #008.
- Remove reactflow dep (superseded by @xyflow/react), test harness
  cmds, and legacy 0001_init.sql backups.

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 09:40:08 +02:00
1 parent 37a56dc6b6
commit 9e3aa3aa17
150 files changed
+6856 -10995

No files matched your search

-1
View File
@@ -32,7 +32,6 @@ RUN addgroup -g 1001 -S appgroup && \
WORKDIR /app WORKDIR /app
COPY --from=go-builder /app/main ./main COPY --from=go-builder /app/main ./main
COPY --from=go-builder /app/migrations ./migrations
COPY --from=go-builder /app/migrations_goose ./migrations_goose COPY --from=go-builder /app/migrations_goose ./migrations_goose
COPY --from=auth-builder /auth ./auth COPY --from=auth-builder /auth ./auth
+4 -5
View File
@@ -170,8 +170,7 @@ internal/
├── config/ - Configuration management ├── config/ - Configuration management
├── database/ - Database connections and migrations ├── database/ - Database connections and migrations
└── middleware/ - HTTP middleware └── middleware/ - HTTP middleware
migrations/ - SQL migration files migrations_goose/ - Goose-managed migrations (single migration path)
migrations_goose/ - Goose-managed migrations for new schema changes
``` ```
### Adding New Endpoints ### Adding New Endpoints
@@ -179,9 +178,9 @@ migrations_goose/ - Goose-managed migrations for new schema changes
1. Create handler functions in `internal/api/` 1. Create handler functions in `internal/api/`
2. Add routes in `internal/api/routes.go` 2. Add routes in `internal/api/routes.go`
3. Update database schema: 3. Update database schema:
- Legacy chain lives in `migrations/` and is executed first. - All migrations live in `migrations_goose/` using goose format (`-- +goose Up/Down`).
- New migrations must be added to `migrations_goose/` using goose format (`-- +goose Up/Down`). - `20260401163000_baseline.sql` carries the consolidated schema; append new files after it.
- Keep legacy files immutable after they are shipped. - Never edit the baseline in place; always add a new migration file.
### SQLC (Type-Safe Queries) ### SQLC (Type-Safe Queries)
-29
View File
@@ -1,29 +0,0 @@
package main
import (
"fmt"
"log"
"net/http"
"containr/internal/apwhy/api"
"containr/internal/apwhy/config"
"containr/internal/apwhy/storage"
)
func main() {
cfg := config.Load()
store, err := storage.Open(cfg)
if err != nil {
log.Fatalf("failed to initialize storage: %v", err)
}
defer store.Close()
server := api.NewServer(store, cfg)
addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("APwhy server listening on http://localhost%s", addr)
if err := http.ListenAndServe(addr, server.Handler()); err != nil {
log.Fatalf("server exited: %v", err)
}
}
+4 -19
View File
@@ -25,11 +25,6 @@ func main() {
} }
defer db.Close() defer db.Close()
legacyDir := os.Getenv("LEGACY_MIGRATIONS_DIR")
if legacyDir == "" {
legacyDir = "migrations"
}
gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR") gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR")
if gooseDir == "" { if gooseDir == "" {
gooseDir = "migrations_goose" gooseDir = "migrations_goose"
@@ -41,24 +36,14 @@ func main() {
} }
switch command { switch command {
case "up": case "up", "goose-up":
migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout) migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout)
if err := db.MigrateAllWithLock(migrationCtx, legacyDir, gooseDir); err != nil { if err := db.MigrateAllWithLock(migrationCtx, gooseDir); err != nil {
migrationCancel() migrationCancel()
log.Fatalf("Migration failed: %v", err) log.Fatalf("Migration failed: %v", err)
} }
migrationCancel() migrationCancel()
log.Println("Legacy + goose migrations completed successfully") log.Println("Migrations completed successfully")
case "legacy-up":
if err := db.Migrate(legacyDir); err != nil {
log.Fatalf("Legacy migration failed: %v", err)
}
log.Println("Legacy migrations completed successfully")
case "goose-up":
if err := db.MigrateGoose(gooseDir); err != nil {
log.Fatalf("Goose migration failed: %v", err)
}
log.Println("Goose migrations completed successfully")
case "goose-status": case "goose-status":
if err := db.GooseStatus(gooseDir); err != nil { if err := db.GooseStatus(gooseDir); err != nil {
log.Fatalf("Goose status failed: %v", err) log.Fatalf("Goose status failed: %v", err)
@@ -73,6 +58,6 @@ func main() {
} }
log.Printf("Created goose migration %q in %q", name, gooseDir) log.Printf("Created goose migration %q in %q", name, gooseDir)
default: default:
log.Fatalf("Unknown command %q. Supported commands: up | legacy-up | goose-up | goose-status | goose-create", command) log.Fatalf("Unknown command %q. Supported commands: up | goose-status | goose-create", command)
} }
} }
+1 -1
View File
@@ -39,7 +39,7 @@ func main() {
// Run startup migrations unless explicitly disabled. // Run startup migrations unless explicitly disabled.
if cfg.AutoMigrate { if cfg.AutoMigrate {
migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout) migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout)
if err := db.MigrateAllWithLock(migrationCtx, "migrations", "migrations_goose"); err != nil { if err := db.MigrateAllWithLock(migrationCtx, "migrations_goose"); err != nil {
migrationCancel() migrationCancel()
log.Fatalf("Failed to run database migrations: %v", err) log.Fatalf("Failed to run database migrations: %v", err)
} }
@@ -1,54 +0,0 @@
package main
import (
"context"
"fmt"
"containr/internal/build"
)
func main() {
fmt.Println("🧪 Testing Build Manager Detection...")
// Test build type detection on the current project
fmt.Println("\n📁 Testing on current project (has package.json)...")
// Note: We can't fully test BuildManager without a docker client,
// but we can test the detection logic
// Create mock scenarios
testCases := []struct {
name string
expected string
}{
{"Node.js project (package.json)", "railpack"},
{"Python project (requirements.txt)", "railpack"},
{"Go project (go.mod)", "railpack"},
{"Dockerfile project", "dockerfile"},
{"Unknown project", "nixpacks"},
}
fmt.Println("\n🎯 Expected detection priorities:")
for _, tc := range testCases {
fmt.Printf(" • %s → %s\n", tc.name, tc.expected)
}
// Test Railpack builder directly
builder := build.NewRailpackBuilder("/tmp/test", nil)
fmt.Println("\n🔍 Testing Railpack detection on current project:")
err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr")
if err != nil {
fmt.Printf("❌ Detection failed: %v\n", err)
} else {
fmt.Println("✅ Railpack can build this project!")
}
fmt.Println("\n📋 Build Priority Order:")
fmt.Println(" 1. Dockerfile (if present)")
fmt.Println(" 2. Railpack (primary choice)")
fmt.Println(" 3. Nixpacks (fallback)")
fmt.Println(" 4. Prebuilt (manual)")
fmt.Println("\n🚀 Build Manager Integration Test Complete!")
}
-32
View File
@@ -1,32 +0,0 @@
package main
import (
"context"
"fmt"
"containr/internal/build"
)
func main() {
// Create a RailpackBuilder
builder := build.NewRailpackBuilder("/tmp/containr-build-test", nil)
// Test detection on the current project (has package.json)
fmt.Println("Testing Railpack detection on current project...")
err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr")
if err != nil {
fmt.Printf("❌ Railpack detection failed: %v\n", err)
} else {
fmt.Println("✅ Railpack can build this project!")
}
// Show supported frameworks
fmt.Println("\nSupported frameworks:")
frameworks := builder.GetSupportedFrameworks()
for _, fw := range frameworks {
fmt.Printf(" - %s\n", fw)
}
fmt.Println("\n✅ Railpack integration test completed successfully!")
}
+1 -18
View File
@@ -8,11 +8,9 @@ require (
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.1
github.com/go-redis/redis/v8 v8.11.5 github.com/go-redis/redis/v8 v8.11.5
github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.0.0 github.com/golang-jwt/jwt/v5 v5.0.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.7.1
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
github.com/pressly/goose/v3 v3.24.1 github.com/pressly/goose/v3 v3.24.1
@@ -22,13 +20,9 @@ require (
github.com/sqlc-dev/pqtype v0.3.0 github.com/sqlc-dev/pqtype v0.3.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.47.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
modernc.org/sqlite v1.39.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -40,7 +34,6 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
@@ -52,11 +45,6 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // 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/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
@@ -68,13 +56,11 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect github.com/morikuni/aec v1.1.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
@@ -95,7 +81,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
@@ -106,7 +91,5 @@ require (
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect
modernc.org/libc v1.66.3 // indirect modernc.org/sqlite v1.39.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )
-40
View File
@@ -1,5 +1,3 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
@@ -63,8 +61,6 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
@@ -74,8 +70,6 @@ github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -84,18 +78,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZ
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -259,36 +241,14 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY=
modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
-74
View File
@@ -1,74 +0,0 @@
package analytics
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type Client struct {
BaseURL string
APIKey string
WebsiteID string
HTTP *http.Client
}
func NewClient(baseURL, apiKey, websiteID string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
WebsiteID: websiteID,
HTTP: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *Client) Enabled() bool {
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
}
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
if !c.Enabled() {
return map[string]any{
"enabled": false,
}, nil
}
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
if err != nil {
return nil, err
}
query := u.Query()
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Accept", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
}
var payload map[string]any
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, err
}
payload["enabled"] = true
return payload, nil
}
+377 -228
View File
@@ -1,7 +1,10 @@
package api package api
import ( import (
"context"
"crypto/subtle" "crypto/subtle"
"database/sql"
"encoding/json"
"fmt" "fmt"
"math" "math"
"net/http" "net/http"
@@ -9,26 +12,29 @@ import (
"strings" "strings"
"time" "time"
"containr/internal/database"
"containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "github.com/sqlc-dev/pqtype"
) )
// NodeAgent represents a container orchestration agent // NodeAgent represents a container orchestration agent
type NodeAgent struct { type NodeAgent struct {
ID string `json:"id" gorm:"primaryKey"` ID string `json:"id"`
Name string `json:"name" gorm:"not null"` Name string `json:"name"`
Hostname string `json:"hostname" gorm:"not null"` Hostname string `json:"hostname"`
IPAddress string `json:"ip_address" gorm:"not null"` IPAddress string `json:"ip_address"`
Port int `json:"port" gorm:"not null"` Port int `json:"port"`
Status string `json:"status" gorm:"default:'offline'"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"` Capabilities AgentCapabilities `json:"capabilities"`
Resources NodeResources `json:"resources" gorm:"serializer:json"` Resources NodeResources `json:"resources"`
LastHeartbeat time.Time `json:"last_heartbeat"` LastHeartbeat time.Time `json:"last_heartbeat"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Metadata map[string]interface{} `json:"metadata" gorm:"serializer:json"` Metadata map[string]interface{} `json:"metadata"`
} }
// AgentCapabilities defines what the agent can do // AgentCapabilities defines what the agent can do
@@ -89,20 +95,20 @@ type BandwidthInfo struct {
// ContainerInstance represents a container running on an agent // ContainerInstance represents a container running on an agent
type ContainerInstance struct { type ContainerInstance struct {
ID string `json:"id" gorm:"primaryKey"` ID string `json:"id"`
Name string `json:"name" gorm:"not null"` Name string `json:"name"`
Image string `json:"image" gorm:"not null"` Image string `json:"image"`
ProjectID string `json:"project_id" gorm:"not null"` ProjectID string `json:"project_id"`
ServiceID string `json:"service_id" gorm:"not null"` ServiceID string `json:"service_id"`
NodeAgentID string `json:"node_agent_id" gorm:"not null"` NodeAgentID string `json:"node_agent_id"`
Status ContainerStatus `json:"status" gorm:"serializer:json"` Status ContainerStatus `json:"status"`
Resources ContainerResources `json:"resources" gorm:"serializer:json"` Resources ContainerResources `json:"resources"`
Ports []PortMapping `json:"ports" gorm:"serializer:json"` Ports []PortMapping `json:"ports"`
Environment map[string]string `json:"environment" gorm:"serializer:json"` Environment map[string]string `json:"environment"`
Volumes []VolumeMount `json:"volumes" gorm:"serializer:json"` Volumes []VolumeMount `json:"volumes"`
Networks []string `json:"networks" gorm:"serializer:json"` Networks []string `json:"networks"`
RestartPolicy RestartPolicy `json:"restart_policy" gorm:"serializer:json"` RestartPolicy RestartPolicy `json:"restart_policy"`
HealthCheck *HealthCheck `json:"health_check" gorm:"serializer:json"` HealthCheck *HealthCheck `json:"health_check"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"` StartedAt *time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -155,12 +161,12 @@ type HealthCheck struct {
// AgentCommand represents a command sent to an agent // AgentCommand represents a command sent to an agent
type AgentCommand struct { type AgentCommand struct {
ID string `json:"id" gorm:"primaryKey"` ID string `json:"id"`
Type string `json:"type" gorm:"not null"` Type string `json:"type"`
NodeAgentID string `json:"node_agent_id" gorm:"not null"` NodeAgentID string `json:"node_agent_id"`
ContainerID *string `json:"container_id"` ContainerID *string `json:"container_id"`
Payload map[string]interface{} `json:"payload" gorm:"serializer:json"` Payload map[string]interface{} `json:"payload"`
Status string `json:"status" gorm:"default:'pending'"` Status string `json:"status"`
Result *string `json:"result"` Result *string `json:"result"`
Error *string `json:"error"` Error *string `json:"error"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -181,23 +187,6 @@ type AgentHeartbeat struct {
Version string `json:"version"` Version string `json:"version"`
} }
type AgentHeartbeatRecord struct {
ID string `json:"id" gorm:"primaryKey"`
NodeAgentID string `json:"node_agent_id" gorm:"index;not null"`
Timestamp time.Time `json:"timestamp" gorm:"index;not null"`
Status string `json:"status"`
Resources NodeResources `json:"resources" gorm:"serializer:json"`
ContainerCount int `json:"container_count"`
SystemLoad SystemLoad `json:"system_load" gorm:"serializer:json"`
Uptime int64 `json:"uptime"`
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
}
func (AgentHeartbeatRecord) TableName() string {
return "agent_heartbeats"
}
type SystemLoad struct { type SystemLoad struct {
Load1M float64 `json:"load_1m"` Load1M float64 `json:"load_1m"`
Load5M float64 `json:"load_5m"` Load5M float64 `json:"load_5m"`
@@ -206,13 +195,125 @@ type SystemLoad struct {
// NodeAgentHandler handles agent-related endpoints // NodeAgentHandler handles agent-related endpoints
type NodeAgentHandler struct { type NodeAgentHandler struct {
db *gorm.DB q *sqlcdb.Queries
} }
func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler { func NewNodeAgentHandler(db *database.DB) *NodeAgentHandler {
return &NodeAgentHandler{db: db} return &NodeAgentHandler{q: sqlcdb.New(db.DB)}
} }
// --- row <-> API type conversion helpers ---
func rawJSON(v interface{}) pqtype.NullRawMessage {
if v == nil {
return pqtype.NullRawMessage{}
}
b, err := json.Marshal(v)
if err != nil {
return pqtype.NullRawMessage{}
}
return pqtype.NullRawMessage{RawMessage: b, Valid: true}
}
func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) {
if raw.Valid && len(raw.RawMessage) > 0 {
_ = json.Unmarshal(raw.RawMessage, dst)
}
}
func agentFromRow(row sqlcdb.NodeAgent) NodeAgent {
agent := NodeAgent{
ID: row.ID,
Name: row.Name,
Hostname: row.Hostname,
IPAddress: row.IpAddress,
Port: int(row.Port),
Status: row.Status.String,
Version: row.Version.String,
Metadata: map[string]interface{}{},
}
if row.LastHeartbeat.Valid {
agent.LastHeartbeat = row.LastHeartbeat.Time
}
if row.CreatedAt.Valid {
agent.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
agent.UpdatedAt = row.UpdatedAt.Time
}
unmarshalRaw(row.Capabilities, &agent.Capabilities)
unmarshalRaw(row.Resources, &agent.Resources)
unmarshalRaw(row.Metadata, &agent.Metadata)
return agent
}
func containerFromRow(row sqlcdb.ContainerInstance) ContainerInstance {
container := ContainerInstance{
ID: row.ID,
Name: row.Name,
Image: row.Image,
ProjectID: row.ProjectID,
ServiceID: row.ServiceID,
NodeAgentID: row.NodeAgentID,
}
if row.CreatedAt.Valid {
container.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
container.UpdatedAt = row.UpdatedAt.Time
}
if row.StartedAt.Valid {
t := row.StartedAt.Time
container.StartedAt = &t
}
unmarshalRaw(row.Status, &container.Status)
unmarshalRaw(row.Resources, &container.Resources)
unmarshalRaw(row.Ports, &container.Ports)
unmarshalRaw(row.Environment, &container.Environment)
unmarshalRaw(row.Volumes, &container.Volumes)
unmarshalRaw(row.Networks, &container.Networks)
unmarshalRaw(row.RestartPolicy, &container.RestartPolicy)
if row.HealthCheck.Valid {
var hc HealthCheck
if err := json.Unmarshal(row.HealthCheck.RawMessage, &hc); err == nil {
container.HealthCheck = &hc
}
}
return container
}
func commandFromRow(row sqlcdb.AgentCommand) AgentCommand {
cmd := AgentCommand{
ID: row.ID,
Type: row.Type,
NodeAgentID: row.NodeAgentID,
Status: row.Status.String,
}
if row.ContainerID.Valid {
cmd.ContainerID = &row.ContainerID.String
}
if row.Result.Valid {
cmd.Result = &row.Result.String
}
if row.Error.Valid {
cmd.Error = &row.Error.String
}
if row.CreatedAt.Valid {
cmd.CreatedAt = row.CreatedAt.Time
}
if row.UpdatedAt.Valid {
cmd.UpdatedAt = row.UpdatedAt.Time
}
if row.CompletedAt.Valid {
t := row.CompletedAt.Time
cmd.CompletedAt = &t
}
unmarshalRaw(row.Payload, &cmd.Payload)
return cmd
}
// --- handlers ---
// RegisterAgent handles agent registration // RegisterAgent handles agent registration
func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
var req struct { var req struct {
@@ -235,54 +336,48 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
return return
} }
// Check if agent already exists ctx := c.Request.Context()
var existingAgent NodeAgent
if err := h.db.Where("hostname = ? AND ip_address = ?", req.Hostname, req.IPAddress).First(&existingAgent).Error; err == nil {
// Update existing agent
existingAgent.Name = req.Name
existingAgent.Port = req.Port
existingAgent.Capabilities = req.Capabilities
existingAgent.Status = "connecting"
existingAgent.LastHeartbeat = time.Now()
if err := h.db.Save(&existingAgent).Error; err != nil { // Re-registering an existing host updates it in place.
if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{
Hostname: req.Hostname,
IpAddress: req.IPAddress,
}); err == nil {
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
ID: existing.ID,
Name: req.Name,
Hostname: existing.Hostname,
IpAddress: existing.IpAddress,
Port: int32(req.Port),
Status: sql.NullString{String: "connecting", Valid: true},
Version: existing.Version,
Capabilities: rawJSON(req.Capabilities),
Resources: existing.Resources,
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: existing.Metadata,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"agent_id": existingAgent.ID, "agent_id": updated.ID,
"auth_token": authToken, "auth_token": authToken,
"status": "updated", "status": "updated",
}) })
return return
} }
// Create new agent agentID := uuid.New().String()
agent := NodeAgent{ resources := NodeResources{
ID: uuid.New().String(), CPU: CPUResources{Cores: 4},
Name: req.Name,
Hostname: req.Hostname,
IPAddress: req.IPAddress,
Port: req.Port,
Status: "connecting",
Capabilities: req.Capabilities,
Resources: NodeResources{
CPU: CPUResources{
Cores: 4,
Allocation: 0,
Usage: 0,
},
Memory: MemoryResources{ Memory: MemoryResources{
Total: 8 * 1024 * 1024 * 1024, // 8GB Total: 8 * 1024 * 1024 * 1024, // 8GB
Allocated: 0,
Used: 0,
Available: 8 * 1024 * 1024 * 1024, Available: 8 * 1024 * 1024 * 1024,
}, },
Storage: StorageResources{ Storage: StorageResources{
Total: 100 * 1024 * 1024 * 1024, // 100GB Total: 100 * 1024 * 1024 * 1024, // 100GB
Allocated: 0,
Used: 0,
Available: 100 * 1024 * 1024 * 1024, Available: 100 * 1024 * 1024 * 1024,
}, },
Network: NetworkResources{ Network: NetworkResources{
@@ -295,25 +390,29 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
Status: "up", Status: "up",
}, },
}, },
Bandwidth: BandwidthInfo{
Inbound: 0,
Outbound: 0,
}, },
},
},
LastHeartbeat: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Metadata: make(map[string]interface{}),
} }
if err := h.db.Create(&agent).Error; err != nil { _, err := h.q.CreateAgent(ctx, sqlcdb.CreateAgentParams{
ID: agentID,
Name: req.Name,
Hostname: req.Hostname,
IpAddress: req.IPAddress,
Port: int32(req.Port),
Status: sql.NullString{String: "connecting", Valid: true},
Version: sql.NullString{},
Capabilities: rawJSON(req.Capabilities),
Resources: rawJSON(resources),
LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true},
Metadata: rawJSON(map[string]interface{}{}),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"})
return return
} }
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
"agent_id": agent.ID, "agent_id": agentID,
"auth_token": authToken, "auth_token": authToken,
"status": "registered", "status": "registered",
}) })
@@ -321,22 +420,25 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
// GetAgents returns all registered agents // GetAgents returns all registered agents
func (h *NodeAgentHandler) GetAgents(c *gin.Context) { func (h *NodeAgentHandler) GetAgents(c *gin.Context) {
var agents []NodeAgent rows, err := h.q.ListAgents(c.Request.Context())
if err := h.db.Find(&agents).Error; err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"})
return return
} }
agents := make([]NodeAgent, 0, len(rows))
for _, row := range rows {
agents = append(agents, agentFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"agents": agents}) c.JSON(http.StatusOK, gin.H{"agents": agents})
} }
// GetAgent returns a specific agent // GetAgent returns a specific agent
func (h *NodeAgentHandler) GetAgent(c *gin.Context) { func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
id := c.Param("id") row, err := h.q.GetAgent(c.Request.Context(), c.Param("id"))
if err != nil {
var agent NodeAgent if err == sql.ErrNoRows {
if err := h.db.First(&agent, "id = ?", id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return return
} }
@@ -344,16 +446,17 @@ func (h *NodeAgentHandler) GetAgent(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"agent": agent}) c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(row)})
} }
// UpdateAgent updates an agent's information // UpdateAgent updates an agent's information
func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) { func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
id := c.Param("id") id := c.Param("id")
ctx := c.Request.Context()
var agent NodeAgent row, err := h.q.GetAgent(ctx, id)
if err := h.db.First(&agent, "id = ?", id).Error; err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return return
} }
@@ -367,19 +470,53 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) {
return return
} }
if err := h.db.Model(&agent).Updates(updates).Error; err != nil { agent := agentFromRow(row)
applyAgentUpdates(&agent, updates)
updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{
ID: agent.ID,
Name: agent.Name,
Hostname: agent.Hostname,
IpAddress: agent.IPAddress,
Port: int32(agent.Port),
Status: sql.NullString{String: agent.Status, Valid: agent.Status != ""},
Version: sql.NullString{String: agent.Version, Valid: agent.Version != ""},
Capabilities: rawJSON(agent.Capabilities),
Resources: rawJSON(agent.Resources),
LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()},
Metadata: rawJSON(agent.Metadata),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return return
} }
c.JSON(http.StatusOK, gin.H{"agent": agent}) c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)})
}
// applyAgentUpdates merges whitelisted keys from an arbitrary update map.
func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) {
remarshal := func(key string, dst interface{}) {
if v, ok := updates[key]; ok {
if b, err := json.Marshal(v); err == nil {
_ = json.Unmarshal(b, dst)
}
}
}
remarshal("name", &agent.Name)
remarshal("hostname", &agent.Hostname)
remarshal("ip_address", &agent.IPAddress)
remarshal("port", &agent.Port)
remarshal("status", &agent.Status)
remarshal("version", &agent.Version)
remarshal("capabilities", &agent.Capabilities)
remarshal("resources", &agent.Resources)
remarshal("metadata", &agent.Metadata)
} }
// DeleteAgent removes an agent // DeleteAgent removes an agent
func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) { func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) {
id := c.Param("id") if err := h.q.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil {
if err := h.db.Delete(&NodeAgent{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"})
return return
} }
@@ -403,9 +540,9 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
heartbeat.Timestamp = time.Now() heartbeat.Timestamp = time.Now()
} }
var agent NodeAgent ctx := c.Request.Context()
if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil { if _, err := h.q.GetAgent(ctx, heartbeat.NodeAgentID); err != nil {
if err == gorm.ErrRecordNotFound { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return return
} }
@@ -413,36 +550,32 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
return return
} }
// Update agent status and resources if err := h.q.UpdateAgentHeartbeat(ctx, sqlcdb.UpdateAgentHeartbeatParams{
agent.Status = heartbeat.Status ID: heartbeat.NodeAgentID,
agent.Resources = heartbeat.Resources Status: sql.NullString{String: heartbeat.Status, Valid: heartbeat.Status != ""},
agent.LastHeartbeat = heartbeat.Timestamp Resources: rawJSON(heartbeat.Resources),
agent.UpdatedAt = time.Now() LastHeartbeat: sql.NullTime{Time: heartbeat.Timestamp, Valid: true},
}); err != nil {
if err := h.db.Save(&agent).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"})
return return
} }
record := AgentHeartbeatRecord{ err := h.q.InsertAgentHeartbeat(ctx, sqlcdb.InsertAgentHeartbeatParams{
ID: uuid.New().String(), ID: uuid.New().String(),
NodeAgentID: heartbeat.NodeAgentID, NodeAgentID: heartbeat.NodeAgentID,
Timestamp: heartbeat.Timestamp, Timestamp: heartbeat.Timestamp,
Status: heartbeat.Status, Status: heartbeat.Status,
Resources: heartbeat.Resources, Resources: rawJSON(heartbeat.Resources).RawMessage,
ContainerCount: heartbeat.ContainerCount, ContainerCount: int32(heartbeat.ContainerCount),
SystemLoad: heartbeat.SystemLoad, SystemLoad: rawJSON(heartbeat.SystemLoad).RawMessage,
Uptime: heartbeat.Uptime, Uptime: heartbeat.Uptime,
Version: heartbeat.Version, Version: heartbeat.Version,
CreatedAt: time.Now(), })
} if err != nil && !isMissingTableError(err) {
if err := h.db.Create(&record).Error; err != nil {
// Keep heartbeat endpoint available even if history table is not yet migrated. // Keep heartbeat endpoint available even if history table is not yet migrated.
if !isMissingTableError(err) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"})
return return
} }
}
c.Status(http.StatusOK) c.Status(http.StatusOK)
} }
@@ -454,17 +587,17 @@ func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
return return
} }
agentID := c.Param("id") rows, err := h.q.ListPendingCommands(c.Request.Context(), c.Param("id"))
var commands []AgentCommand if err != nil {
if err := h.db.
Where("node_agent_id = ? AND status = ?", agentID, "pending").
Order("created_at ASC").
Limit(25).
Find(&commands).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
return return
} }
commands := make([]AgentCommand, 0, len(rows))
for _, row := range rows {
commands = append(commands, commandFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"commands": commands}) c.JSON(http.StatusOK, gin.H{"commands": commands})
} }
@@ -491,45 +624,39 @@ func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
return return
} }
var command AgentCommand ctx := c.Request.Context()
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil { command, err := h.q.CompleteCommand(ctx, sqlcdb.CompleteCommandParams{
if err == gorm.ErrRecordNotFound { ID: commandID,
NodeAgentID: agentID,
Status: sql.NullString{String: req.Status, Valid: true},
Result: sql.NullString{String: req.Result, Valid: req.Result != ""},
Error: sql.NullString{String: req.Error, Valid: req.Error != ""},
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"})
return
}
now := time.Now()
command.Status = req.Status
command.UpdatedAt = now
command.CompletedAt = &now
if req.Result != "" {
command.Result = &req.Result
}
if req.Error != "" {
command.Error = &req.Error
}
if err := h.db.Save(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"})
return return
} }
if command.ContainerID != nil { out := commandFromRow(command)
h.updateContainerStatusAfterCommand(command) if out.ContainerID != nil {
h.updateContainerStatusAfterCommand(ctx, out)
} }
c.JSON(http.StatusOK, gin.H{"command": command}) c.JSON(http.StatusOK, gin.H{"command": out})
} }
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) { func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) {
var container ContainerInstance row, err := h.q.GetContainer(ctx, *command.ContainerID)
if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil { if err != nil {
return return
} }
container := containerFromRow(row)
if command.Status == "failed" { if command.Status == "failed" {
errorMessage := "" errorMessage := ""
if command.Error != nil { if command.Error != nil {
@@ -537,7 +664,10 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
} }
container.Status.State = "error" container.Status.State = "error"
container.Status.Error = &errorMessage container.Status.Error = &errorMessage
_ = h.db.Save(&container).Error _ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
ID: container.ID,
Status: rawJSON(container.Status),
})
return return
} }
@@ -555,20 +685,25 @@ func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentComman
container.Status.State = "removed" container.Status.State = "removed"
container.Status.FinishedAt = &now container.Status.FinishedAt = &now
} }
container.UpdatedAt = now _ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{
_ = h.db.Save(&container).Error ID: container.ID,
Status: rawJSON(container.Status),
})
} }
// GetAgentContainers returns containers running on a specific agent // GetAgentContainers returns containers running on a specific agent
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) { func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
agentID := c.Param("id") rows, err := h.q.ListContainersForAgent(c.Request.Context(), c.Param("id"))
if err != nil {
var containers []ContainerInstance
if err := h.db.Where("node_agent_id = ?", agentID).Find(&containers).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"})
return return
} }
containers := make([]ContainerInstance, 0, len(rows))
for _, row := range rows {
containers = append(containers, containerFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"containers": containers}) c.JSON(http.StatusOK, gin.H{"containers": containers})
} }
@@ -595,10 +730,11 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
return return
} }
ctx := c.Request.Context()
// Verify agent exists // Verify agent exists
var agent NodeAgent if _, err := h.q.GetAgent(ctx, agentID); err != nil {
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { if err == sql.ErrNoRows {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return return
} }
@@ -606,17 +742,15 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
return return
} }
containerID := uuid.New().String()
container := ContainerInstance{ container := ContainerInstance{
ID: uuid.New().String(), ID: containerID,
Name: req.Name, Name: req.Name,
Image: req.Image, Image: req.Image,
ProjectID: req.ProjectID, ProjectID: req.ProjectID,
ServiceID: req.ServiceID, ServiceID: req.ServiceID,
NodeAgentID: agentID, NodeAgentID: agentID,
Status: ContainerStatus{ Status: ContainerStatus{State: "created", Health: "none"},
State: "created",
Health: "none",
},
Resources: req.Resources, Resources: req.Resources,
Ports: req.Ports, Ports: req.Ports,
Environment: req.Environment, Environment: req.Environment,
@@ -628,26 +762,36 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) {
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
if err := h.db.Create(&container).Error; err != nil { _, err := h.q.CreateContainer(ctx, sqlcdb.CreateContainerParams{
ID: container.ID,
Name: container.Name,
Image: container.Image,
ProjectID: container.ProjectID,
ServiceID: container.ServiceID,
NodeAgentID: container.NodeAgentID,
Status: rawJSON(container.Status),
Resources: rawJSON(container.Resources),
Ports: rawJSON(container.Ports),
Environment: rawJSON(container.Environment),
Volumes: rawJSON(container.Volumes),
Networks: rawJSON(container.Networks),
RestartPolicy: rawJSON(container.RestartPolicy),
HealthCheck: rawJSON(container.HealthCheck),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"})
return return
} }
// Create command to start container on agent // Create command to start container on agent
command := AgentCommand{ _, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
ID: uuid.New().String(), ID: uuid.New().String(),
Type: "create_container", Type: "create_container",
NodeAgentID: agentID, NodeAgentID: agentID,
ContainerID: &container.ID, ContainerID: sql.NullString{String: container.ID, Valid: true},
Payload: map[string]interface{}{ Payload: rawJSON(map[string]interface{}{"container": container}),
"container": container, })
}, if err != nil {
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"})
return return
} }
@@ -669,45 +813,44 @@ func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) {
return return
} }
command := AgentCommand{ command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{
ID: uuid.New().String(), ID: uuid.New().String(),
Type: req.Type, Type: req.Type,
NodeAgentID: agentID, NodeAgentID: agentID,
Payload: req.Payload, Payload: rawJSON(req.Payload),
Status: "pending", })
CreatedAt: time.Now(), if err != nil {
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return return
} }
c.JSON(http.StatusCreated, gin.H{"command": command}) c.JSON(http.StatusCreated, gin.H{"command": commandFromRow(command)})
} }
// GetAgentCommands returns commands for an agent // GetAgentCommands returns commands for an agent
func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) { func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) {
agentID := c.Param("id") rows, err := h.q.ListCommandsForAgent(c.Request.Context(), c.Param("id"))
if err != nil {
var commands []AgentCommand
if err := h.db.Where("node_agent_id = ?", agentID).Order("created_at DESC").Find(&commands).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
return return
} }
commands := make([]AgentCommand, 0, len(rows))
for _, row := range rows {
commands = append(commands, commandFromRow(row))
}
c.JSON(http.StatusOK, gin.H{"commands": commands}) c.JSON(http.StatusOK, gin.H{"commands": commands})
} }
// GetCommandStatus returns the status of a specific command // GetCommandStatus returns the status of a specific command
func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) { func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
agentID := c.Param("id") command, err := h.q.GetCommandForAgent(c.Request.Context(), sqlcdb.GetCommandForAgentParams{
commandID := c.Param("commandId") ID: c.Param("commandId"),
NodeAgentID: c.Param("id"),
var command AgentCommand })
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil { if err != nil {
if err == gorm.ErrRecordNotFound { if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
return return
} }
@@ -715,7 +858,7 @@ func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) {
return return
} }
c.JSON(http.StatusOK, gin.H{"command": command}) c.JSON(http.StatusOK, gin.H{"command": commandFromRow(command)})
} }
// ContainerAction handles container lifecycle actions // ContainerAction handles container lifecycle actions
@@ -739,10 +882,15 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
return return
} }
ctx := c.Request.Context()
// Verify container exists // Verify container exists
var container ContainerInstance containerRow, err := h.q.GetContainerForAgent(ctx, sqlcdb.GetContainerForAgentParams{
if err := h.db.First(&container, "id = ? AND node_agent_id = ?", containerID, agentID).Error; err != nil { ID: containerID,
if err == gorm.ErrRecordNotFound { NodeAgentID: agentID,
})
if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"})
return return
} }
@@ -751,22 +899,18 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
} }
// Create command for the action // Create command for the action
command := AgentCommand{ _, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{
ID: uuid.New().String(), ID: uuid.New().String(),
Type: fmt.Sprintf("%s_container", action), Type: fmt.Sprintf("%s_container", action),
NodeAgentID: agentID, NodeAgentID: agentID,
ContainerID: &container.ID, ContainerID: sql.NullString{String: containerRow.ID, Valid: true},
Payload: map[string]interface{}{ Payload: rawJSON(map[string]interface{}{
"container_id": containerID, "container_id": containerID,
"container_name": container.Name, "container_name": containerRow.Name,
"docker_name": container.Name, "docker_name": containerRow.Name,
}, }),
Status: "pending", })
CreatedAt: time.Now(), if err != nil {
UpdatedAt: time.Now(),
}
if err := h.db.Create(&command).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"})
return return
} }
@@ -794,22 +938,23 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
return return
} }
var agent NodeAgent ctx := c.Request.Context()
if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { agentRow, err := h.q.GetAgent(ctx, agentID)
if err == gorm.ErrRecordNotFound { if err != nil {
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"})
return return
} }
agent := agentFromRow(agentRow)
from := time.Now().Add(-duration) from := time.Now().Add(-duration)
var records []AgentHeartbeatRecord records, queryErr := h.q.ListAgentHeartbeatsSince(ctx, sqlcdb.ListAgentHeartbeatsSinceParams{
queryErr := h.db. NodeAgentID: agentID,
Where("node_agent_id = ? AND timestamp >= ?", agentID, from). Timestamp: from,
Order("timestamp ASC"). })
Find(&records).Error
if queryErr != nil && !isMissingTableError(queryErr) { if queryErr != nil && !isMissingTableError(queryErr) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"})
return return
@@ -817,7 +962,11 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) {
metrics := make([]map[string]interface{}, 0, len(records)) metrics := make([]map[string]interface{}, 0, len(records))
for _, record := range records { for _, record := range records {
metrics = append(metrics, buildMetricPoint(record.Timestamp, record.Resources, record.SystemLoad, record.ContainerCount)) var resources NodeResources
var load SystemLoad
_ = json.Unmarshal(record.Resources, &resources)
_ = json.Unmarshal(record.SystemLoad, &load)
metrics = append(metrics, buildMetricPoint(record.Timestamp, resources, load, int(record.ContainerCount)))
} }
if len(metrics) == 0 { if len(metrics) == 0 {
+125 -69
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand" "crypto/rand"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@@ -13,21 +14,14 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// Constants for validation and limits // Default quota limits applied when the request omits them.
const ( const (
MaxServiceNameLength = 100
MaxRoutePrefixLength = 200
MaxUpstreamURLLength = 500
MaxAPIKeyNameLength = 100
MinAPIKeyLength = 20
MaxAPIKeyLength = 100
DefaultRPMLimit = 60 DefaultRPMLimit = 60
DefaultMonthlyQuota = 1000 DefaultMonthlyQuota = 1000
MaxRPMLimit = 10000
MaxMonthlyQuota = 10000000
) )
// Validator instance for request validation // Validator instance for request validation
@@ -75,15 +69,6 @@ type APIKeyRequest struct {
MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"` MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"`
} }
// generateServiceID generates a cryptographically secure service ID
func generateServiceID() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return "svc_" + hex.EncodeToString(bytes), nil
}
// generateAPIKey generates a cryptographically secure API key // generateAPIKey generates a cryptographically secure API key
func generateAPIKey() (string, error) { func generateAPIKey() (string, error) {
bytes := make([]byte, 32) bytes := make([]byte, 32)
@@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error {
return nil return nil
} }
// isUniqueViolation reports whether err is a Postgres unique-constraint violation.
func isUniqueViolation(err error) bool {
var pqErr *pq.Error
return errors.As(err, &pqErr) && pqErr.Code == "23505"
}
// handleAPwhyServiceValidate probes the upstream health path and records the
// result on the service row. Ported from the standalone APwhy server.
func handleAPwhyServiceValidate(c *gin.Context) {
serviceID := c.Param("id")
db := c.MustGet("db").(*database.DB)
ctx := c.Request.Context()
var upstreamURL, healthPath string
var authHeader, authValue sql.NullString
var timeoutMs sql.NullInt64
err := db.QueryRowContext(ctx, `
SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms
FROM api_services WHERE id = $1
`, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs)
if err != nil {
sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil)
return
}
timeout := 8000
if timeoutMs.Valid && timeoutMs.Int64 > 0 {
timeout = int(timeoutMs.Int64)
}
target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/")
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if authHeader.Valid && authHeader.String != "" {
req.Header.Set(authHeader.String, authValue.String)
}
client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond}
res, probeErr := client.Do(req)
status := http.StatusBadGateway
message := ""
ok := false
if probeErr != nil {
message = probeErr.Error()
} else {
defer res.Body.Close()
status = res.StatusCode
message = fmt.Sprintf("HTTP %d", res.StatusCode)
ok = res.StatusCode >= 200 && res.StatusCode < 300
}
validationStatus := "failed"
if ok {
validationStatus = "healthy"
}
_, _ = db.ExecContext(ctx, `
UPDATE api_services
SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW()
WHERE id = $3
`, validationStatus, message, serviceID)
if !ok {
_, _ = db.ExecContext(ctx, `
INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at)
VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW())
`, serviceID, message, status)
}
sendSuccessResponse(c, http.StatusOK, map[string]interface{}{
"validation": map[string]interface{}{
"ok": ok,
"status": status,
"message": message,
},
})
}
// handleAPwhyServicesList returns a list of API services // handleAPwhyServicesList returns a list of API services
func handleAPwhyServicesList(c *gin.Context) { func handleAPwhyServicesList(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
@@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) {
} }
defer rows.Close() defer rows.Close()
var services []map[string]interface{} services := make([]map[string]interface{}, 0)
for rows.Next() { for rows.Next() {
var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string
var enabled bool var enabled int
err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt) err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt)
if err != nil { if err != nil {
continue // Skip malformed rows continue // Skip malformed rows
@@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) {
"slug": slug, "slug": slug,
"upstreamUrl": upstreamURL, "upstreamUrl": upstreamURL,
"routePrefix": routePrefix, "routePrefix": routePrefix,
"enabled": enabled, "enabled": enabled != 0,
"createdAt": createdAt, "createdAt": createdAt,
"updatedAt": updatedAt, "updatedAt": updatedAt,
}) })
@@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
ctx := context.Background() ctx := context.Background()
// Generate slug and ID
id, err := generateServiceID()
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
"Failed to generate service ID", err.Error())
return
}
slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")) slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-"))
// Insert service into database
query := `
INSERT INTO api_services (
id, name, slug, upstream_url, route_prefix, health_path,
enabled, rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, '/health', true, $6, $7, NOW(), NOW())
`
var rpmLimit, monthlyQuota int var rpmLimit, monthlyQuota int
if req.RPMLimit != nil { if req.RPMLimit != nil {
rpmLimit = *req.RPMLimit rpmLimit = *req.RPMLimit
@@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) {
monthlyQuota = DefaultMonthlyQuota monthlyQuota = DefaultMonthlyQuota
} }
_, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL, // id defaults to gen_random_uuid(); enabled defaults to 1.
req.RoutePrefix, rpmLimit, monthlyQuota) var id string
err := db.QueryRowContext(ctx, `
INSERT INTO api_services (
name, slug, upstream_url, route_prefix, health_path,
rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW())
RETURNING id
`, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id)
if err != nil { if err != nil {
if isUniqueViolation(err) {
sendErrorResponse(c, http.StatusConflict, "CONFLICT",
"A service with this slug or route prefix already exists", err.Error())
return
}
sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR", sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR",
"Failed to create service", err.Error()) "Failed to create service", err.Error())
return return
@@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
if input.Enabled != nil { if input.Enabled != nil {
enabled := 0
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(), _, err := db.ExecContext(context.Background(),
"UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2", "UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, serviceID) enabled, serviceID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) {
} }
defer rows.Close() defer rows.Close()
var keys []map[string]interface{} keys := make([]map[string]interface{}, 0)
for rows.Next() { for rows.Next() {
var id, name, keyPrefix, plan, createdAt, updatedAt string var id, name, keyPrefix, plan, createdAt, updatedAt string
var enabled bool var enabled int
var rpmLimit, monthlyQuota sql.NullInt64 var rpmLimit, monthlyQuota sql.NullInt64
err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt) err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt)
@@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) {
"name": name, "name": name,
"keyPrefix": keyPrefix, "keyPrefix": keyPrefix,
"plan": plan, "plan": plan,
"enabled": enabled, "enabled": enabled != 0,
"createdAt": createdAt, "createdAt": createdAt,
"updatedAt": updatedAt, "updatedAt": updatedAt,
} }
@@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) {
return return
} }
// Generate ID and prefix
id, err := generateServiceID()
if err != nil {
sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR",
"Failed to generate key ID", err.Error())
return
}
keyPrefix := apiKey[:8] keyPrefix := apiKey[:8]
// Set default limits based on plan // Set default limits based on plan
var rpmLimit, monthlyQuota int plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan)
switch input.Plan { if err != nil {
case "free": sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR",
rpmLimit, monthlyQuota = 60, 1000 "Invalid plan", err.Error())
case "pro": return
rpmLimit, monthlyQuota = 600, 50000
case "business":
rpmLimit, monthlyQuota = 3000, 300000
default:
rpmLimit, monthlyQuota = 60, 1000
} }
// Insert key into database // id defaults to gen_random_uuid(); enabled defaults to 1.
_, err = db.ExecContext(context.Background(), ` var id string
err = db.QueryRowContext(context.Background(), `
INSERT INTO api_keys ( INSERT INTO api_keys (
id, name, key_hash, key_prefix, plan, allowed_service_ids, name, key_hash, key_prefix, plan, allowed_service_ids,
enabled, rpm_limit, monthly_quota, created_at, updated_at rpm_limit, monthly_quota, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW()) ) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW())
`, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota) RETURNING id
`, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) {
"data": gin.H{ "data": gin.H{
"id": id, "id": id,
"name": input.Name, "name": input.Name,
"plan": input.Plan, "plan": plan,
"key": apiKey, // Only return the actual key once "key": apiKey, // Only return the actual key once
"keyPrefix": keyPrefix, "keyPrefix": keyPrefix,
"enabled": true, "enabled": true,
@@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) {
db := c.MustGet("db").(*database.DB) db := c.MustGet("db").(*database.DB)
if input.Enabled != nil { if input.Enabled != nil {
enabled := 0
if *input.Enabled {
enabled = 1
}
_, err := db.ExecContext(context.Background(), _, err := db.ExecContext(context.Background(),
"UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2", "UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2",
*input.Enabled, keyID) enabled, keyID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
+15 -1
View File
@@ -1,6 +1,10 @@
package api package api
import "github.com/gin-gonic/gin" import (
"strings"
"github.com/gin-gonic/gin"
)
// firstPathParam returns the first non-empty route param from the provided names. // firstPathParam returns the first non-empty route param from the provided names.
func firstPathParam(c *gin.Context, names ...string) string { func firstPathParam(c *gin.Context, names ...string) string {
@@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string {
} }
return "" return ""
} }
// firstNonEmpty returns the first non-blank string, trimmed.
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
+16 -44
View File
@@ -17,8 +17,6 @@ import (
"containr/internal/scaling" "containr/internal/scaling"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/driver/postgres"
"gorm.io/gorm"
) )
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) { func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
@@ -56,14 +54,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Initialize scaling handler // Initialize scaling handler
scalingHandler := NewScalingHandler(autoScaler) scalingHandler := NewScalingHandler(autoScaler)
// Initialize GORM for agent system // Initialize agent handler (sqlc-backed)
gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{}) agentHandler := NewNodeAgentHandler(db)
if err != nil {
panic("Failed to initialize GORM: " + err.Error())
}
// Initialize agent handler
agentHandler := NewNodeAgentHandler(gormDB)
// Initialize database handler // Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB, dockerClient) databaseHandler := NewDatabaseHandler(db.DB, dockerClient)
@@ -90,7 +82,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
c.Set("auto_scaler", autoScaler) c.Set("auto_scaler", autoScaler)
c.Set("ha_manager", haManager) c.Set("ha_manager", haManager)
c.Set("scaling_handler", scalingHandler) c.Set("scaling_handler", scalingHandler)
c.Set("gorm_db", gormDB)
c.Next() c.Next()
}) })
@@ -298,42 +289,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Audit Logs routes // Audit Logs routes
protected.GET("/audit-logs", handleGetAuditLogs) protected.GET("/audit-logs", handleGetAuditLogs)
protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs)
}
// APwhy Gateway routes // API Gateway routes (merged APwhy) - namespaced to avoid
apwhy := router.Group("/api/v1") // colliding with Containr's own /services paths.
gateway := protected.Group("/gateway")
{ {
// Health check (no auth required) gateway.GET("/services", handleAPwhyServicesList)
apwhy.GET("/health", func(c *gin.Context) { gateway.POST("/services", handleAPwhyServicesCreate)
c.JSON(200, gin.H{ gateway.PATCH("/services/:id", handleAPwhyServicesPatch)
"ok": true, gateway.GET("/services/:id/validate", handleAPwhyServiceValidate)
"data": gin.H{
"status": "ok", gateway.GET("/keys", handleAPwhyKeysList)
"name": "Containr + APwhy", gateway.POST("/keys", handleAPwhyKeysCreate)
"database": "postgresql", gateway.PATCH("/keys/:id", handleAPwhyKeysPatch)
"generatedAt": time.Now().UTC().Format(time.RFC3339),
}, gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps)
}) gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
})
} }
// Protected APwhy routes (authentication required)
protectedAPwhy := router.Group("/api/v1")
protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret))
{
// Service management
protectedAPwhy.GET("/services", handleAPwhyServicesList)
protectedAPwhy.POST("/services", handleAPwhyServicesCreate)
protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch)
// API Keys
protectedAPwhy.GET("/keys", handleAPwhyKeysList)
protectedAPwhy.POST("/keys", handleAPwhyKeysCreate)
protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch)
// Analytics
protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps)
protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic)
} }
} }
} }
File diff suppressed because it is too large. Load diff
@@ -1 +0,0 @@
@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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 truncated
@@ -1 +0,0 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s<e.observers.length;s+=1){const i=e.observers[s],o=xe&&xe.running;o&&xe.disposed.has(i),(o?!i.tState:!i.state)&&(i.pure?I.push(i):q.push(i),i.observers&&Ke(i)),o||(i.state=J)}if(I.length>1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspenLine truncated
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>APwhy</title>
<script type="module" crossorigin src="/assets/index-DwfYiTMH.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DRUelTBf.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+2 -2
View File
@@ -310,8 +310,8 @@ func handleCreateFromTemplate(c *gin.Context) {
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""}, ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""}, BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""}, StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
Type: serviceType, Type: sql.NullString{String: serviceType, Valid: true},
Status: "stopped", Status: sql.NullString{String: "stopped", Valid: true},
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""}, Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""}, Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
Environment: sql.NullString{String: serviceEnvironment, Valid: true}, Environment: sql.NullString{String: serviceEnvironment, Valid: true},
@@ -1,74 +0,0 @@
package analytics
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
)
type Client struct {
BaseURL string
APIKey string
WebsiteID string
HTTP *http.Client
}
func NewClient(baseURL, apiKey, websiteID string) *Client {
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
WebsiteID: websiteID,
HTTP: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *Client) Enabled() bool {
return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != ""
}
func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) {
if !c.Enabled() {
return map[string]any{
"enabled": false,
}, nil
}
u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID))
if err != nil {
return nil, err
}
query := u.Query()
query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli()))
query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli()))
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Accept", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode >= 400 {
return nil, fmt.Errorf("umami returned %d", res.StatusCode)
}
var payload map[string]any
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, err
}
payload["enabled"] = true
return payload, nil
}
File diff suppressed because it is too large. Load diff
@@ -1,321 +0,0 @@
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strings"
"testing"
"containr/internal/apwhy/config"
"containr/internal/apwhy/storage"
)
type apiErrorPayload struct {
Code string `json:"code"`
Message string `json:"message"`
}
type apiResponsePayload struct {
OK bool `json:"ok"`
Data any `json:"data"`
Error apiErrorPayload `json:"error"`
}
func newTestServer(t *testing.T) (*httptest.Server, *http.Client) {
t.Helper()
cfg := config.Load()
cfg.SQLitePath = ":memory:"
cfg.CookieSecure = false
store, err := storage.Open(cfg)
if err != nil {
t.Fatalf("failed to open test storage: %v", err)
}
server := NewServer(store, cfg)
ts := httptest.NewServer(server.Handler())
t.Cleanup(func() {
ts.Close()
_ = store.Close()
})
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("failed to create cookie jar: %v", err)
}
return ts, &http.Client{Jar: jar}
}
func requestJSON(t *testing.T, client *http.Client, method string, url string, body string) (int, apiResponsePayload) {
t.Helper()
var bodyReader *bytes.Reader
if body == "" {
bodyReader = bytes.NewReader(nil)
} else {
bodyReader = bytes.NewReader([]byte(body))
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
var payload apiResponsePayload
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode response body: %v", err)
}
return resp.StatusCode, payload
}
func bootstrapAndLoginOwner(t *testing.T, client *http.Client, baseURL string) {
t.Helper()
status, payload := requestJSON(
t,
client,
http.MethodPost,
baseURL+"/api/v1/bootstrap/register-owner",
`{"email":"[email protected]","password":"owner-pass-123"}`,
)
if status != http.StatusCreated || !payload.OK {
t.Fatalf("owner bootstrap failed: status=%d payload=%+v", status, payload)
}
status, payload = requestJSON(
t,
client,
http.MethodPost,
baseURL+"/api/v1/auth/login",
`{"email":"[email protected]","password":"owner-pass-123"}`,
)
if status != http.StatusOK || !payload.OK {
t.Fatalf("owner login failed: status=%d payload=%+v", status, payload)
}
}
func TestServerBootstrapLoginAndProtectedRoutes(t *testing.T) {
ts, authClient := newTestServer(t)
baseURL := ts.URL
// Bootstrap should be open before any user exists.
status, payload := requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
if status != http.StatusOK || !payload.OK {
t.Fatalf("unexpected bootstrap status response: status=%d payload=%+v", status, payload)
}
bootstrapData, ok := payload.Data.(map[string]any)
if !ok {
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
}
if hasUsers, _ := bootstrapData["hasUsers"].(bool); hasUsers {
t.Fatalf("expected hasUsers=false before owner bootstrap")
}
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); !registrationOpen {
t.Fatalf("expected registrationOpen=true before owner bootstrap")
}
// Protected route without auth should fail.
anonClient := &http.Client{}
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
if status != http.StatusUnauthorized || payload.OK {
t.Fatalf("expected unauthorized /auth/me response, got status=%d payload=%+v", status, payload)
}
// Register owner and login.
bootstrapAndLoginOwner(t, authClient, baseURL)
// Bootstrap should close once owner exists.
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/bootstrap/status", "")
if status != http.StatusOK || !payload.OK {
t.Fatalf("unexpected bootstrap status after owner creation: status=%d payload=%+v", status, payload)
}
bootstrapData, ok = payload.Data.(map[string]any)
if !ok {
t.Fatalf("expected object payload for bootstrap status, got %#v", payload.Data)
}
if hasUsers, _ := bootstrapData["hasUsers"].(bool); !hasUsers {
t.Fatalf("expected hasUsers=true after owner bootstrap")
}
if registrationOpen, _ := bootstrapData["registrationOpen"].(bool); registrationOpen {
t.Fatalf("expected registrationOpen=false after owner bootstrap")
}
// Owner session cookie should authorize /auth/me.
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
if status != http.StatusOK || !payload.OK {
t.Fatalf("expected successful /auth/me response, got status=%d payload=%+v", status, payload)
}
meData, ok := payload.Data.(map[string]any)
if !ok {
t.Fatalf("expected object payload for auth/me, got %#v", payload.Data)
}
if email, _ := meData["email"].(string); email != "[email protected]" {
t.Fatalf("unexpected /auth/me email: %q", email)
}
// Logout should revoke auth session.
status, payload = requestJSON(t, authClient, http.MethodPost, baseURL+"/api/v1/auth/logout", "")
if status != http.StatusOK || !payload.OK {
t.Fatalf("expected successful logout response, got status=%d payload=%+v", status, payload)
}
status, payload = requestJSON(t, authClient, http.MethodGet, baseURL+"/api/v1/auth/me", "")
if status != http.StatusUnauthorized || payload.OK {
t.Fatalf("expected unauthorized /auth/me after logout, got status=%d payload=%+v", status, payload)
}
}
func TestServerServicesCreateValidationRules(t *testing.T) {
ts, client := newTestServer(t)
baseURL := ts.URL
bootstrapAndLoginOwner(t, client, baseURL)
// Route prefix under /api must be rejected.
status, payload := requestJSON(
t,
client,
http.MethodPost,
baseURL+"/api/v1/services",
`{"name":"Bad Service","upstreamUrl":"http://example.com","routePrefix":"/api/conflict"}`,
)
if status != http.StatusBadRequest || payload.OK {
t.Fatalf("expected route conflict validation failure, got status=%d payload=%+v", status, payload)
}
if payload.Error.Code != "ROUTE_CONFLICT" {
t.Fatalf("expected ROUTE_CONFLICT error code, got %q", payload.Error.Code)
}
// Valid service should be created successfully.
status, payload = requestJSON(
t,
client,
http.MethodPost,
baseURL+"/api/v1/services",
`{"name":"Web Frontend","upstreamUrl":"http://example.com","routePrefix":"/web"}`,
)
if status != http.StatusCreated || !payload.OK {
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
}
createData, ok := payload.Data.(map[string]any)
if !ok {
t.Fatalf("expected object payload for service creation, got %#v", payload.Data)
}
if _, ok := createData["id"].(string); !ok {
t.Fatalf("expected created service id in response")
}
if slug, _ := createData["slug"].(string); !strings.Contains(slug, "web-frontend") {
t.Fatalf("expected normalized service slug, got %q", slug)
}
// List should contain the newly created service.
status, payload = requestJSON(t, client, http.MethodGet, baseURL+"/api/v1/services", "")
if status != http.StatusOK || !payload.OK {
t.Fatalf("expected successful services list response, got status=%d payload=%+v", status, payload)
}
rows, ok := payload.Data.([]any)
if !ok || len(rows) == 0 {
t.Fatalf("expected non-empty services list, got %#v", payload.Data)
}
}
func TestServerProxyWithAPIKey(t *testing.T) {
ts, authClient := newTestServer(t)
baseURL := ts.URL
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-api-key") != "" {
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, "unexpected-api-key-header")
return
}
_, _ = io.WriteString(w, "ok:"+r.URL.Path+"?"+r.URL.RawQuery)
}))
t.Cleanup(upstream.Close)
bootstrapAndLoginOwner(t, authClient, baseURL)
// Create service exposed at /web/* and proxied to the upstream test server.
status, payload := requestJSON(
t,
authClient,
http.MethodPost,
baseURL+"/api/v1/services",
`{"name":"Web Proxy","upstreamUrl":"`+upstream.URL+`","routePrefix":"/web"}`,
)
if status != http.StatusCreated || !payload.OK {
t.Fatalf("expected successful service creation, got status=%d payload=%+v", status, payload)
}
// Mint API key for gateway access.
status, payload = requestJSON(
t,
authClient,
http.MethodPost,
baseURL+"/api/v1/keys",
`{"name":"integration-key","plan":"free"}`,
)
if status != http.StatusCreated || !payload.OK {
t.Fatalf("expected successful key creation, got status=%d payload=%+v", status, payload)
}
keyData, ok := payload.Data.(map[string]any)
if !ok {
t.Fatalf("expected object payload for key creation, got %#v", payload.Data)
}
rawKey, _ := keyData["key"].(string)
if strings.TrimSpace(rawKey) == "" {
t.Fatalf("expected non-empty raw API key")
}
// No API key should be rejected.
anonClient := &http.Client{}
status, payload = requestJSON(t, anonClient, http.MethodGet, baseURL+"/web/ping?x=1", "")
if status != http.StatusUnauthorized || payload.OK {
t.Fatalf("expected unauthorized proxy request without key, got status=%d payload=%+v", status, payload)
}
if payload.Error.Code != "API_KEY_MISSING" {
t.Fatalf("expected API_KEY_MISSING error code, got %q", payload.Error.Code)
}
// Valid API key should proxy request and strip the key before forwarding.
req, err := http.NewRequest(http.MethodGet, baseURL+"/web/ping?x=1", nil)
if err != nil {
t.Fatalf("failed to create proxy request: %v", err)
}
req.Header.Set("x-api-key", rawKey)
resp, err := anonClient.Do(req)
if err != nil {
t.Fatalf("proxy request failed: %v", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read proxy response body: %v", err)
}
body := string(bodyBytes)
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected proxy status 200, got %d body=%q", resp.StatusCode, body)
}
if body != "ok:/ping?x=1" {
t.Fatalf("unexpected proxy body: %q", body)
}
}
@@ -1 +0,0 @@
@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,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) format("woff2"),url(data:font/woff;base64,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 truncated
@@ -1 +0,0 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s<e.observers.length;s+=1){const i=e.observers[s],o=xe&&xe.running;o&&xe.disposed.has(i),(o?!i.tState:!i.state)&&(i.pure?I.push(i):q.push(i),i.observers&&Ke(i)),o||(i.state=J)}if(I.length>1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspenLine truncated
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more