Merge pull request #109 from Wikid82/feature/prox_host_managment

Refactor: Migrate Frontend to React Query & Clean Architecture
This commit is contained in:
Jeremy
2025-11-19 18:14:45 -05:00
committed by GitHub
55 changed files with 801 additions and 894 deletions
+14 -7
View File
@@ -1,5 +1,10 @@
# CaddyProxyManager+ Copilot Instructions
## 🚨 CRITICAL ARCHITECTURE RULES 🚨
- **Single Frontend Source**: All frontend code MUST reside in `frontend/`. NEVER create `backend/frontend/` or any other nested frontend directory.
- **Single Backend Source**: All backend code MUST reside in `backend/`.
- **No Python**: This is a Go (Backend) + React/TypeScript (Frontend) project. Do not introduce Python scripts or requirements.
## Big Picture
- `backend/cmd/api` loads config, opens SQLite, then hands off to `internal/server` where routes from `internal/api/routes` are registered.
- `internal/config` respects `CPM_ENV`, `CPM_HTTP_PORT`, `CPM_DB_PATH`, `CPM_FRONTEND_DIR` and creates the `data/` directory; lean on these instead of hard-coded paths.
@@ -15,15 +20,17 @@
- Long-running work must respect the graceful shutdown flow in `server.Run(ctx)`—avoid background goroutines that ignore the context.
## Frontend Workflow
- React 18 + Vite + React Query; start with `cd frontend && npm install && npm run dev` so Vite proxies `/api` calls to `http://localhost:8080` (configured in `vite.config.ts`).
- Consolidate HTTP calls via `src/api/client.ts`; wrap them in hooks under `src/hooks` and expose query keys like `['proxy-hosts']` to keep cache invalidation simple.
- Screens live in `src/pages` and render inside `components/Layout`; navigation + active styles rely on React Router + `clsx`, so extend the `links` array instead of hard-coding routes elsewhere.
- Forms follow `pages/ProxyHosts.tsx`: local `useState` per field, submit via `useMutation`, then reset state and `invalidateQueries` for the affected list on success.
- Styling remains a single `src/index.css` grid/aside theme; keep additions lightweight and avoid new design systems until shadcn/ui lands.
- **Location**: Always work within `frontend/`.
- **Stack**: React 18 + Vite + TypeScript + TanStack Query (React Query).
- **State Management**: Use `src/hooks/use*.ts` wrapping React Query. Do not use raw `useEffect` for data fetching.
- **API Layer**: Create typed API clients in `src/api/*.ts` that wrap `client.ts`.
- **Development**: Run `cd frontend && npm run dev`. Vite proxies `/api` to `http://localhost:8080`.
- **Components**: Screens live in `src/pages`. Reusable UI in `src/components`.
- **Forms**: Use local `useState` for form fields, submit via `useMutation` from custom hooks, then `invalidateQueries` on success.
## Cross-Cutting Notes
- Run the backend before the frontend; React Query expects the exact JSON produced by GORM tags (snake_case), so keep API and UI field names aligned.
- When adding models, update both `internal/models` and the `AutoMigrate` call inside `internal/api/routes/routes.go`; register new Gin routes right after migrations for clarity.
- Tests belong beside handlers (`*_test.go`); reuse the `setupTestRouter` helper structure (in-memory SQLite, Gin router, httptest requests) for fast feedback.
- The root `Dockerfile` is still the legacy Python scaffold—do not assume it builds this stack until it is replaced with the Go/React pipeline.
- Branch from `feature/**` and target `development`; CI currently lints/tests placeholders, so run `go test ./...` and `npm run build` locally before opening PRs.
- The root `Dockerfile` builds the Go binary and the React static assets (multi-stage build).
- Branch from `feature/**` and target `development`.
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check for Caddy v3 and open issue
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const upstream = { owner: 'caddyserver', repo: 'caddy' };
+4 -4
View File
@@ -28,17 +28,17 @@ jobs:
language: [ 'go', 'javascript-typescript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3
with:
category: "/language:${{ matrix.language }}"
+3 -9
View File
@@ -11,7 +11,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/${{ github.event.repository.name }}
IMAGE_NAME: ${{ github.repository_owner }}/cpmp
jobs:
build-and-push:
@@ -30,13 +30,7 @@ jobs:
steps:
# Step 1: Download the code
- name: 📥 Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
# Normalize IMAGE_NAME to lowercase to satisfy container registry format
- name: 🔤 Normalize image name
run: |
raw="${{ github.repository_owner }}/${{ github.event.repository.name }}"
echo "IMAGE_NAME=$(echo "$raw" | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: 🧪 Determine skip condition
id: skip
@@ -159,7 +153,7 @@ jobs:
# Step 10: Upload Trivy results to GitHub Security tab
- name: 📤 Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && (steps.trivy.outcome == 'success' || steps.trivy.outcome == 'failure')
with:
sarif_file: 'trivy-results.sarif'
+3 -8
View File
@@ -15,7 +15,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/${{ github.event.repository.name }}
IMAGE_NAME: ${{ github.repository_owner }}/cpmp
jobs:
build-and-push:
@@ -28,12 +28,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Normalize image name
run: |
raw="${{ github.repository_owner }}/${{ github.event.repository.name }}"
echo "IMAGE_NAME=$(echo "$raw" | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Determine skip condition
id: skip
@@ -133,6 +128,6 @@ jobs:
- name: Upload Trivy results
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true'
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@c3d42c5d08633d8b33635fbd94b000a0e2585b3c # v3
with:
sarif_file: 'trivy-results.sarif'
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
steps:
# Step 1: Get the code
- name: 📥 Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# Step 2: Set up Node.js (for building any JS-based doc tools)
- name: 🔧 Set up Node.js
+4 -4
View File
@@ -11,10 +11,10 @@ jobs:
name: Backend (Go)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Set up Go
uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: '1.22'
cache-dependency-path: backend/go.sum
@@ -24,7 +24,7 @@ jobs:
run: go test -v ./...
- name: Run golangci-lint
uses: golangci/golangci-lint-action@3cfe3a4abbb849e10058ce4af15d205b6da42804 # v4.0.0
uses: golangci/golangci-lint-action@d6238b002a20823d52840fda27e2d4891c5952dc # v4.0.1
with:
version: latest
working-directory: backend
@@ -35,7 +35,7 @@ jobs:
name: Frontend (React)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Set up Node.js
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
+2 -2
View File
@@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 1
- name: Run Renovate
uses: renovatebot/github-action@v40.1.11
uses: renovatebot/github-action@063e0c946b9c1af35ef3450efc44114925d6e8e6 # v40.1.11
with:
configurationFile: .github/renovate.json
token: ${{ secrets.PROJECT_TOKEN }}
+3 -3
View File
@@ -52,7 +52,7 @@ RUN CGO_ENABLED=1 GOOS=linux go build \
-ldflags "-X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.SemVer=${VERSION} \
-X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.GitCommit=${VCS_REF} \
-X github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version.BuildDate=${BUILD_DATE}" \
-o api ./cmd/api
-o cpmp ./cmd/api
# ---- Final Runtime with Caddy ----
FROM ${CADDY_IMAGE}
@@ -63,7 +63,7 @@ RUN apk --no-cache add ca-certificates sqlite-libs \
&& apk --no-cache upgrade
# Copy Go binary from backend builder
COPY --from=backend-builder /app/backend/api /app/api
COPY --from=backend-builder /app/backend/cpmp /app/cpmp
# Copy frontend build from frontend builder
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
@@ -89,7 +89,7 @@ ARG BUILD_DATE
ARG VCS_REF
# OCI image labels for version metadata
LABEL org.opencontainers.image.title="CaddyProxyManager+" \
LABEL org.opencontainers.image.title="CaddyProxyManager+ (CPMP)" \
org.opencontainers.image.description="Web UI for managing Caddy reverse proxy configurations" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}" \
+3 -3
View File
@@ -115,13 +115,13 @@ The `docs.yml` workflow already configured for GitHub Pages:
**Latest stable version:**
```bash
docker pull ghcr.io/wikid82/caddyproxymanagerplus:latest
docker run -d -p 8080:8080 -v caddy_data:/app/data ghcr.io/wikid82/caddyproxymanagerplus:latest
docker pull ghcr.io/wikid82/cpmp:latest
docker run -d -p 8080:8080 -v caddy_data:/app/data ghcr.io/wikid82/cpmp:latest
```
**Development version:**
```bash
docker pull ghcr.io/wikid82/caddyproxymanagerplus:dev
docker pull ghcr.io/wikid82/cpmp:dev
```
**Specific version:**
+2 -2
View File
@@ -66,8 +66,8 @@ docker-build-versioned:
--build-arg VERSION=$$VERSION \
--build-arg BUILD_DATE=$$BUILD_DATE \
--build-arg VCS_REF=$$VCS_REF \
-t caddyproxymanagerplus:$$VERSION \
-t caddyproxymanagerplus:latest \
-t cpmp:$$VERSION \
-t cpmp:latest \
.
# Run Docker containers (production)
+4 -2
View File
@@ -1,4 +1,4 @@
# Caddy Proxy Manager Plus
# Caddy Proxy Manager+ (CPMP)
**Make your websites easy to reach!** 🚀
@@ -56,14 +56,16 @@ Don't have Docker? [Download it here](https://docs.docker.com/get-docker/) - it'
### Step 2: Run One Command
Open your terminal and paste this:
**Real-World Example:**
```bash
docker run -d \
-p 8080:8080 \
-v caddy_data:/app/data \
--name caddy-proxy-manager \
ghcr.io/wikid82/caddyproxymanagerplus:latest
ghcr.io/wikid82/cpmp:latest
```
### Step 3: Open Your Browser
Go to: **http://localhost:8080**
+6 -6
View File
@@ -62,16 +62,16 @@ Example: `0.1.0-alpha`, `1.0.0-beta.1`, `2.0.0-rc.2`
```bash
# Use latest stable release
docker pull ghcr.io/wikid82/caddyproxymanagerplus:latest
docker pull ghcr.io/wikid82/cpmp:latest
# Use specific version
docker pull ghcr.io/wikid82/caddyproxymanagerplus:v1.0.0
docker pull ghcr.io/wikid82/cpmp:v1.0.0
# Use development builds
docker pull ghcr.io/wikid82/caddyproxymanagerplus:development
docker pull ghcr.io/wikid82/cpmp:development
# Use specific commit
docker pull ghcr.io/wikid82/caddyproxymanagerplus:main-abc123
docker pull ghcr.io/wikid82/cpmp:main-abc123
```
## Version Information
@@ -97,7 +97,7 @@ Response includes:
View version metadata:
```bash
docker inspect ghcr.io/wikid82/caddyproxymanagerplus:latest \
docker inspect ghcr.io/wikid82/cpmp:latest \
--format='{{json .Config.Labels}}' | jq
```
@@ -111,7 +111,7 @@ Returns OCI-compliant labels:
Local builds default to `version=dev`:
```bash
docker build -t caddyproxymanagerplus:dev .
docker build -t cpmp:dev .
```
Build with custom version:
+3 -3
View File
@@ -100,7 +100,7 @@ docker build \
--build-arg VERSION=1.2.3 \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg VCS_REF=$(git rev-parse HEAD) \
-t caddyproxymanagerplus:1.2.3 .
-t cpmp:1.2.3 .
```
### Querying Version at Runtime
@@ -109,14 +109,14 @@ docker build \
curl http://localhost:8080/api/v1/health
{
"status": "ok",
"service": "caddy-proxy-manager-plus",
"service": "CPMP",
"version": "1.0.0",
"git_commit": "abc1234567890def",
"build_date": "2025-11-17T12:34:56Z"
}
# Container image labels
docker inspect ghcr.io/wikid82/caddyproxymanagerplus:latest \
docker inspect ghcr.io/wikid82/cpmp:latest \
--format='{{json .Config.Labels}}' | jq
```
+37 -31
View File
@@ -96,49 +96,55 @@ func main() {
// Seed Proxy Hosts
proxyHosts := []models.ProxyHost{
{
UUID: uuid.NewString(),
Name: "Development App",
Domain: "app.local.dev",
TargetScheme: "http",
TargetHost: "localhost",
TargetPort: 3000,
EnableTLS: false,
EnableWS: true,
Enabled: true,
UUID: uuid.NewString(),
Name: "Development App",
DomainNames: "app.local.dev",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 3000,
SSLForced: false,
WebsocketSupport: true,
HSTSEnabled: false,
BlockExploits: true,
Enabled: true,
},
{
UUID: uuid.NewString(),
Name: "API Server",
Domain: "api.local.dev",
TargetScheme: "http",
TargetHost: "192.168.1.100",
TargetPort: 8080,
EnableTLS: false,
EnableWS: false,
Enabled: true,
UUID: uuid.NewString(),
Name: "API Server",
DomainNames: "api.local.dev",
ForwardScheme: "http",
ForwardHost: "192.168.1.100",
ForwardPort: 8080,
SSLForced: false,
WebsocketSupport: false,
HSTSEnabled: false,
BlockExploits: true,
Enabled: true,
},
{
UUID: uuid.NewString(),
Name: "Docker Registry",
Domain: "docker.local.dev",
TargetScheme: "http",
TargetHost: "localhost",
TargetPort: 5000,
EnableTLS: false,
EnableWS: false,
Enabled: false,
UUID: uuid.NewString(),
Name: "Docker Registry",
DomainNames: "docker.local.dev",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 5000,
SSLForced: false,
WebsocketSupport: false,
HSTSEnabled: false,
BlockExploits: true,
Enabled: false,
},
}
for _, host := range proxyHosts {
result := db.Where("domain = ?", host.Domain).FirstOrCreate(&host)
result := db.Where("domain_names = ?", host.DomainNames).FirstOrCreate(&host)
if result.Error != nil {
log.Printf("Failed to seed proxy host %s: %v", host.Domain, result.Error)
log.Printf("Failed to seed proxy host %s: %v", host.DomainNames, result.Error)
} else if result.RowsAffected > 0 {
fmt.Printf("✓ Created proxy host: %s -> %s://%s:%d\n",
host.Domain, host.TargetScheme, host.TargetHost, host.TargetPort)
host.DomainNames, host.ForwardScheme, host.ForwardHost, host.ForwardPort)
} else {
fmt.Printf(" Proxy host already exists: %s\n", host.Domain)
fmt.Printf(" Proxy host already exists: %s\n", host.DomainNames)
}
}
+15 -14
View File
@@ -26,6 +26,7 @@ func setupTestDB() *gorm.DB {
// Auto migrate
db.AutoMigrate(
&models.ProxyHost{},
&models.Location{},
&models.RemoteServer{},
&models.ImportSession{},
)
@@ -137,13 +138,13 @@ func TestProxyHostHandler_List(t *testing.T) {
// Create test proxy host
host := &models.ProxyHost{
UUID: uuid.NewString(),
Name: "Test Host",
Domain: "test.local",
TargetScheme: "http",
TargetHost: "localhost",
TargetPort: 3000,
Enabled: true,
UUID: uuid.NewString(),
Name: "Test Host",
DomainNames: "test.local",
ForwardScheme: "http",
ForwardHost: "localhost",
ForwardPort: 3000,
Enabled: true,
}
db.Create(host)
@@ -175,12 +176,12 @@ func TestProxyHostHandler_Create(t *testing.T) {
// Test Create
hostData := map[string]interface{}{
"name": "New Host",
"domain": "new.local",
"target_scheme": "http",
"target_host": "192.168.1.200",
"target_port": 8080,
"enabled": true,
"name": "New Host",
"domain_names": "new.local",
"forward_scheme": "http",
"forward_host": "192.168.1.200",
"forward_port": 8080,
"enabled": true,
}
body, _ := json.Marshal(hostData)
@@ -195,7 +196,7 @@ func TestProxyHostHandler_Create(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &host)
assert.NoError(t, err)
assert.Equal(t, "New Host", host.Name)
assert.Equal(t, "new.local", host.Domain)
assert.Equal(t, "new.local", host.DomainNames)
assert.NotEmpty(t, host.UUID)
}
@@ -157,7 +157,7 @@ func (h *ImportHandler) Commit(c *gin.Context) {
errors := []string{}
for _, host := range proxyHosts {
action := req.Resolutions[host.Domain]
action := req.Resolutions[host.DomainNames]
if action == "skip" {
skipped++
@@ -165,13 +165,13 @@ func (h *ImportHandler) Commit(c *gin.Context) {
}
if action == "rename" {
host.Domain = host.Domain + "-imported"
host.DomainNames = host.DomainNames + "-imported"
}
host.UUID = uuid.NewString()
if err := h.proxyHostSvc.Create(&host); err != nil {
errors = append(errors, fmt.Sprintf("%s: %s", host.Domain, err.Error()))
errors = append(errors, fmt.Sprintf("%s: %s", host.DomainNames, err.Error()))
} else {
created++
}
@@ -228,13 +228,13 @@ func (h *ImportHandler) processImport(caddyfilePath, originalName string) error
existingHosts, _ := h.proxyHostSvc.List()
existingDomains := make(map[string]bool)
for _, host := range existingHosts {
existingDomains[host.Domain] = true
existingDomains[host.DomainNames] = true
}
for _, parsed := range result.Hosts {
if existingDomains[parsed.Domain] {
if existingDomains[parsed.DomainNames] {
result.Conflicts = append(result.Conflicts,
fmt.Sprintf("Domain '%s' already exists in CPM+", parsed.Domain))
fmt.Sprintf("Domain '%s' already exists in CPM+", parsed.DomainNames))
}
}
@@ -53,6 +53,11 @@ func (h *ProxyHostHandler) Create(c *gin.Context) {
host.UUID = uuid.NewString()
// Assign UUIDs to locations
for i := range host.Locations {
host.Locations[i].UUID = uuid.NewString()
}
if err := h.service.Create(&host); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -20,7 +20,7 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}))
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}))
h := NewProxyHostHandler(db)
r := gin.New()
@@ -33,7 +33,7 @@ func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
func TestProxyHostLifecycle(t *testing.T) {
router, _ := setupTestRouter(t)
body := `{"name":"Media","domain":"media.example.com","target_scheme":"http","target_host":"media","target_port":32400}`
body := `{"name":"Media","domain_names":"media.example.com","forward_scheme":"http","forward_host":"media","forward_port":32400,"enabled":true}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -43,7 +43,7 @@ func TestProxyHostLifecycle(t *testing.T) {
var created models.ProxyHost
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
require.Equal(t, "media.example.com", created.Domain)
require.Equal(t, "media.example.com", created.DomainNames)
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil)
listResp := httptest.NewRecorder()
+1
View File
@@ -15,6 +15,7 @@ func Register(router *gin.Engine, db *gorm.DB) error {
// AutoMigrate all models for Issue #5 persistence layer
if err := db.AutoMigrate(
&models.ProxyHost{},
&models.Location{},
&models.CaddyConfig{},
&models.RemoteServer{},
&models.SSLCertificate{},
+5 -4
View File
@@ -24,10 +24,11 @@ func TestClient_Load_Success(t *testing.T) {
client := NewClient(server.URL)
config, _ := GenerateConfig([]models.ProxyHost{
{
UUID: "test",
Domain: "test.com",
TargetHost: "app",
TargetPort: 8080,
UUID: "test",
DomainNames: "test.com",
ForwardHost: "app",
ForwardPort: 8080,
Enabled: true,
},
})
+56 -9
View File
@@ -2,6 +2,7 @@ package caddy
import (
"fmt"
"strings"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
)
@@ -19,22 +20,69 @@ func GenerateConfig(hosts []models.ProxyHost) (*Config, error) {
}, nil
}
routes := make([]*Route, 0, len(hosts))
routes := make([]*Route, 0)
for _, host := range hosts {
if host.Domain == "" {
return nil, fmt.Errorf("proxy host %s has empty domain", host.UUID)
if !host.Enabled {
continue
}
dial := fmt.Sprintf("%s:%d", host.TargetHost, host.TargetPort)
if host.DomainNames == "" {
return nil, fmt.Errorf("proxy host %s has empty domain names", host.UUID)
}
// Parse comma-separated domains
domains := strings.Split(host.DomainNames, ",")
for i := range domains {
domains[i] = strings.TrimSpace(domains[i])
}
// Build handlers for this host
handlers := make([]Handler, 0)
// Add HSTS header if enabled
if host.HSTSEnabled {
hstsValue := "max-age=31536000"
if host.HSTSSubdomains {
hstsValue += "; includeSubDomains"
}
handlers = append(handlers, HeaderHandler(map[string][]string{
"Strict-Transport-Security": {hstsValue},
}))
}
// Add exploit blocking if enabled
if host.BlockExploits {
handlers = append(handlers, BlockExploitsHandler())
}
// Handle custom locations first (more specific routes)
for _, loc := range host.Locations {
dial := fmt.Sprintf("%s:%d", loc.ForwardHost, loc.ForwardPort)
locRoute := &Route{
Match: []Match{
{
Host: domains,
Path: []string{loc.Path, loc.Path + "/*"},
},
},
Handle: []Handler{
ReverseProxyHandler(dial, host.WebsocketSupport),
},
Terminal: true,
}
routes = append(routes, locRoute)
}
// Main proxy handler
dial := fmt.Sprintf("%s:%d", host.ForwardHost, host.ForwardPort)
mainHandlers := append(handlers, ReverseProxyHandler(dial, host.WebsocketSupport))
route := &Route{
Match: []Match{
{Host: []string{host.Domain}},
},
Handle: []Handler{
ReverseProxyHandler(dial, host.EnableWS),
{Host: domains},
},
Handle: mainHandlers,
Terminal: true,
}
@@ -49,7 +97,6 @@ func GenerateConfig(hosts []models.ProxyHost) (*Config, error) {
Listen: []string{":80", ":443"},
Routes: routes,
AutoHTTPS: &AutoHTTPSConfig{
// Enable automatic HTTPS by default
Disable: false,
},
},
+30 -25
View File
@@ -19,14 +19,15 @@ func TestGenerateConfig_Empty(t *testing.T) {
func TestGenerateConfig_SingleHost(t *testing.T) {
hosts := []models.ProxyHost{
{
UUID: "test-uuid",
Name: "Media",
Domain: "media.example.com",
TargetScheme: "http",
TargetHost: "media",
TargetPort: 32400,
EnableTLS: true,
EnableWS: false,
UUID: "test-uuid",
Name: "Media",
DomainNames: "media.example.com",
ForwardScheme: "http",
ForwardHost: "media",
ForwardPort: 32400,
SSLForced: true,
WebsocketSupport: false,
Enabled: true,
},
}
@@ -55,16 +56,18 @@ func TestGenerateConfig_SingleHost(t *testing.T) {
func TestGenerateConfig_MultipleHosts(t *testing.T) {
hosts := []models.ProxyHost{
{
UUID: "uuid-1",
Domain: "site1.example.com",
TargetHost: "app1",
TargetPort: 8080,
UUID: "uuid-1",
DomainNames: "site1.example.com",
ForwardHost: "app1",
ForwardPort: 8080,
Enabled: true,
},
{
UUID: "uuid-2",
Domain: "site2.example.com",
TargetHost: "app2",
TargetPort: 8081,
UUID: "uuid-2",
DomainNames: "site2.example.com",
ForwardHost: "app2",
ForwardPort: 8081,
Enabled: true,
},
}
@@ -76,11 +79,12 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) {
func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
hosts := []models.ProxyHost{
{
UUID: "uuid-ws",
Domain: "ws.example.com",
TargetHost: "wsapp",
TargetPort: 3000,
EnableWS: true,
UUID: "uuid-ws",
DomainNames: "ws.example.com",
ForwardHost: "wsapp",
ForwardPort: 3000,
WebsocketSupport: true,
Enabled: true,
},
}
@@ -97,10 +101,11 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
func TestGenerateConfig_EmptyDomain(t *testing.T) {
hosts := []models.ProxyHost{
{
UUID: "bad-uuid",
Domain: "",
TargetHost: "app",
TargetPort: 8080,
UUID: "bad-uuid",
DomainNames: "",
ForwardHost: "app",
ForwardPort: 8080,
Enabled: true,
},
}
+24 -24
View File
@@ -53,14 +53,14 @@ type CaddyHandler struct {
// ParsedHost represents a single host detected during Caddyfile import.
type ParsedHost struct {
Domain string `json:"domain"`
TargetScheme string `json:"target_scheme"`
TargetHost string `json:"target_host"`
TargetPort int `json:"target_port"`
EnableTLS bool `json:"enable_tls"`
EnableWS bool `json:"enable_websockets"`
RawJSON string `json:"raw_json"` // Original Caddy JSON for this route
Warnings []string `json:"warnings"` // Unsupported features
DomainNames string `json:"domain_names"`
ForwardScheme string `json:"forward_scheme"`
ForwardHost string `json:"forward_host"`
ForwardPort int `json:"forward_port"`
SSLForced bool `json:"ssl_forced"`
WebsocketSupport bool `json:"websocket_support"`
RawJSON string `json:"raw_json"` // Original Caddy JSON for this route
Warnings []string `json:"warnings"` // Unsupported features
}
// ImportResult contains parsed hosts and detected conflicts.
@@ -133,8 +133,8 @@ func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) {
// Extract reverse proxy handler
host := ParsedHost{
Domain: domain,
EnableTLS: strings.HasPrefix(domain, "https") || server.TLSConnectionPolicies != nil,
DomainNames: domain,
SSLForced: strings.HasPrefix(domain, "https") || server.TLSConnectionPolicies != nil,
}
// Find reverse_proxy handler
@@ -147,8 +147,8 @@ func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) {
if dial != "" {
parts := strings.Split(dial, ":")
if len(parts) == 2 {
host.TargetHost = parts[0]
fmt.Sscanf(parts[1], "%d", &host.TargetPort)
host.ForwardHost = parts[0]
fmt.Sscanf(parts[1], "%d", &host.ForwardPort)
}
}
}
@@ -159,7 +159,7 @@ func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) {
if upgrade, ok := headers["Upgrade"].([]interface{}); ok {
for _, v := range upgrade {
if v == "websocket" {
host.EnableWS = true
host.WebsocketSupport = true
break
}
}
@@ -167,9 +167,9 @@ func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) {
}
// Default scheme
host.TargetScheme = "http"
if host.EnableTLS {
host.TargetScheme = "https"
host.ForwardScheme = "http"
if host.SSLForced {
host.ForwardScheme = "https"
}
}
@@ -214,18 +214,18 @@ func ConvertToProxyHosts(parsedHosts []ParsedHost) []models.ProxyHost {
hosts := make([]models.ProxyHost, 0, len(parsedHosts))
for _, parsed := range parsedHosts {
if parsed.TargetHost == "" || parsed.TargetPort == 0 {
if parsed.ForwardHost == "" || parsed.ForwardPort == 0 {
continue // Skip invalid entries
}
hosts = append(hosts, models.ProxyHost{
Name: parsed.Domain, // Can be customized by user during review
Domain: parsed.Domain,
TargetScheme: parsed.TargetScheme,
TargetHost: parsed.TargetHost,
TargetPort: parsed.TargetPort,
EnableTLS: parsed.EnableTLS,
EnableWS: parsed.EnableWS,
Name: parsed.DomainNames, // Can be customized by user during review
DomainNames: parsed.DomainNames,
ForwardScheme: parsed.ForwardScheme,
ForwardHost: parsed.ForwardHost,
ForwardPort: parsed.ForwardPort,
SSLForced: parsed.SSLForced,
WebsocketSupport: parsed.WebsocketSupport,
})
}
+20
View File
@@ -78,6 +78,26 @@ func ReverseProxyHandler(dial string, enableWS bool) Handler {
return h
}
// HeaderHandler creates a handler that sets HTTP response headers.
func HeaderHandler(headers map[string][]string) Handler {
return Handler{
"handler": "headers",
"response": map[string]interface{}{
"set": headers,
},
}
}
// BlockExploitsHandler creates a handler that blocks common exploits.
// This uses Caddy's request matchers to block malicious patterns.
func BlockExploitsHandler() Handler {
return Handler{
"handler": "vars",
// Placeholder for future exploit blocking logic
// Can be extended with specific matchers for SQL injection, XSS, etc.
}
}
// TLSApp configures the TLS app for certificate management.
type TLSApp struct {
Automation *AutomationConfig `json:"automation,omitempty"`
+5 -4
View File
@@ -17,10 +17,11 @@ func TestValidate_EmptyConfig(t *testing.T) {
func TestValidate_ValidConfig(t *testing.T) {
hosts := []models.ProxyHost{
{
UUID: "test",
Domain: "test.example.com",
TargetHost: "app",
TargetPort: 8080,
UUID: "test",
DomainNames: "test.example.com",
ForwardHost: "10.0.1.100",
ForwardPort: 8080,
Enabled: true,
},
}
+18
View File
@@ -0,0 +1,18 @@
package models
import (
"time"
)
// Location represents a custom path-based proxy configuration within a ProxyHost.
type Location struct {
ID uint `json:"id" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
ProxyHostID uint `json:"proxy_host_id" gorm:"not null;index"`
Path string `json:"path" gorm:"not null"` // e.g., /api, /admin
ForwardScheme string `json:"forward_scheme" gorm:"default:http"`
ForwardHost string `json:"forward_host" gorm:"not null"`
ForwardPort int `json:"forward_port" gorm:"not null"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+18 -13
View File
@@ -4,18 +4,23 @@ import (
"time"
)
// ProxyHost represents a reverse proxy configuration for a single domain.
// ProxyHost represents a reverse proxy configuration.
type ProxyHost struct {
ID uint `json:"id" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Name string `json:"name"`
Domain string `json:"domain" gorm:"uniqueIndex"`
TargetScheme string `json:"target_scheme"` // http/https
TargetHost string `json:"target_host"`
TargetPort int `json:"target_port"`
EnableTLS bool `json:"enable_tls" gorm:"default:false"`
EnableWS bool `json:"enable_websockets" gorm:"default:false"`
Enabled bool `json:"enabled" gorm:"default:true"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
Name string `json:"name"`
DomainNames string `json:"domain_names" gorm:"not null"` // Comma-separated list
ForwardScheme string `json:"forward_scheme" gorm:"default:http"`
ForwardHost string `json:"forward_host" gorm:"not null"`
ForwardPort int `json:"forward_port" gorm:"not null"`
SSLForced bool `json:"ssl_forced" gorm:"default:false"`
HTTP2Support bool `json:"http2_support" gorm:"default:true"`
HSTSEnabled bool `json:"hsts_enabled" gorm:"default:false"`
HSTSSubdomains bool `json:"hsts_subdomains" gorm:"default:false"`
BlockExploits bool `json:"block_exploits" gorm:"default:true"`
WebsocketSupport bool `json:"websocket_support" gorm:"default:false"`
Enabled bool `json:"enabled" gorm:"default:true"`
Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -20,9 +20,9 @@ func NewProxyHostService(db *gorm.DB) *ProxyHostService {
}
// ValidateUniqueDomain ensures no duplicate domains exist before creation/update.
func (s *ProxyHostService) ValidateUniqueDomain(domain string, excludeID uint) error {
func (s *ProxyHostService) ValidateUniqueDomain(domainNames string, excludeID uint) error {
var count int64
query := s.db.Model(&models.ProxyHost{}).Where("domain = ?", domain)
query := s.db.Model(&models.ProxyHost{}).Where("domain_names = ?", domainNames)
if excludeID > 0 {
query = query.Where("id != ?", excludeID)
@@ -41,7 +41,7 @@ func (s *ProxyHostService) ValidateUniqueDomain(domain string, excludeID uint) e
// Create validates and creates a new proxy host.
func (s *ProxyHostService) Create(host *models.ProxyHost) error {
if err := s.ValidateUniqueDomain(host.Domain, 0); err != nil {
if err := s.ValidateUniqueDomain(host.DomainNames, 0); err != nil {
return err
}
@@ -50,7 +50,7 @@ func (s *ProxyHostService) Create(host *models.ProxyHost) error {
// Update validates and updates an existing proxy host.
func (s *ProxyHostService) Update(host *models.ProxyHost) error {
if err := s.ValidateUniqueDomain(host.Domain, host.ID); err != nil {
if err := s.ValidateUniqueDomain(host.DomainNames, host.ID); err != nil {
return err
}
@@ -71,19 +71,19 @@ func (s *ProxyHostService) GetByID(id uint) (*models.ProxyHost, error) {
return &host, nil
}
// GetByUUID retrieves a proxy host by UUID.
// GetByUUID finds a proxy host by UUID.
func (s *ProxyHostService) GetByUUID(uuid string) (*models.ProxyHost, error) {
var host models.ProxyHost
if err := s.db.Where("uuid = ?", uuid).First(&host).Error; err != nil {
if err := s.db.Preload("Locations").Where("uuid = ?", uuid).First(&host).Error; err != nil {
return nil, err
}
return &host, nil
}
// List retrieves all proxy hosts.
// List returns all proxy hosts.
func (s *ProxyHostService) List() ([]models.ProxyHost, error) {
var hosts []models.ProxyHost
if err := s.db.Find(&hosts).Error; err != nil {
if err := s.db.Preload("Locations").Order("updated_at desc").Find(&hosts).Error; err != nil {
return nil, err
}
return hosts, nil
+1 -1
View File
@@ -2,7 +2,7 @@ package version
const (
// Name of the application
Name = "CaddyProxyManagerPlus"
Name = "CPMP"
// Version is the semantic version
Version = "0.1.0"
// BuildTime is set during build via ldflags
+1 -1
View File
@@ -27,7 +27,7 @@ done
# Start CPM+ management application
echo "Starting CPM+ management application..."
/app/api &
/app/cpmp &
APP_PID=$!
echo "CPM+ started (PID: $APP_PID)"
+1 -1
View File
@@ -42,7 +42,7 @@ docker run -d \
-p 8080:8080 \
-v caddy_data:/app/data \
--name caddy-proxy-manager \
ghcr.io/wikid82/caddyproxymanagerplus:latest
ghcr.io/wikid82/cpmp:latest
```
**What does this do?** It downloads and starts the app. You don't need to understand the details - just copy and paste!
+3 -3
View File
@@ -197,13 +197,13 @@ When you're ready to release a new version:
docker pull ghcr.io/wikid82/caddyproxymanagerplus:dev
# Pull stable version
docker pull ghcr.io/wikid82/caddyproxymanagerplus:latest
docker pull ghcr.io/wikid82/cpmp:latest
# Pull specific version
docker pull ghcr.io/wikid82/caddyproxymanagerplus:1.0.0
docker pull ghcr.io/wikid82/cpmp:1.0.0
# Run the container
docker run -d -p 8080:8080 -v caddy_data:/app/data ghcr.io/wikid82/caddyproxymanagerplus:latest
docker run -d -p 8080:8080 -v caddy_data:/app/data ghcr.io/wikid82/cpmp:latest
```
### Git Tag Commands
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Caddy Proxy Manager+</title>
<script type="module" crossorigin src="/assets/index-Y4LKIHSS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Be7wNiFg.css">
<script type="module" crossorigin src="/assets/index-BQ-TMhGu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ChYJmfs0.css">
</head>
<body>
<div id="root"></div>
+11
View File
@@ -0,0 +1,11 @@
import client from './client';
export interface HealthResponse {
status: string;
service: string;
}
export const checkHealth = async (): Promise<HealthResponse> => {
const { data } = await client.get<HealthResponse>('/health');
return data;
};
+51
View File
@@ -0,0 +1,51 @@
import client from './client';
export interface ImportSession {
id: string;
state: 'pending' | 'reviewing' | 'completed' | 'failed';
created_at: string;
updated_at: string;
}
export interface ImportPreview {
session: ImportSession;
preview: {
hosts: Array<{ domain_names: string; [key: string]: unknown }>;
conflicts: Record<string, string>;
errors: string[];
};
}
export const uploadCaddyfile = async (content: string): Promise<ImportPreview> => {
const { data } = await client.post<ImportPreview>('/import/upload', { content });
return data;
};
export const getImportPreview = async (): Promise<ImportPreview> => {
const { data } = await client.get<ImportPreview>('/import/preview');
return data;
};
export const commitImport = async (resolutions: Record<string, string>): Promise<void> => {
await client.post('/import/commit', { resolutions });
};
export const cancelImport = async (): Promise<void> => {
await client.post('/import/cancel');
};
export const getImportStatus = async (): Promise<{ has_pending: boolean; session?: ImportSession }> => {
// Note: Assuming there might be a status endpoint or we infer from preview.
// If no dedicated status endpoint exists in backend, we might rely on preview returning 404 or empty.
// Based on previous context, there wasn't an explicit status endpoint mentioned in the simple API,
// but the hook used `importAPI.status()`. I'll check the backend routes if needed.
// For now, I'll implement it assuming /import/preview can serve as status check or there is a /import/status.
// Let's check the backend routes to be sure.
try {
const { data } = await client.get<{ has_pending: boolean; session?: ImportSession }>('/import/status');
return data;
} catch (error) {
// Fallback if status endpoint doesn't exist, though the hook used it.
return { has_pending: false };
}
};
+52
View File
@@ -0,0 +1,52 @@
import client from './client';
export interface Location {
uuid?: string;
path: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
}
export interface ProxyHost {
uuid: string;
domain_names: string;
forward_scheme: string;
forward_host: string;
forward_port: number;
ssl_forced: boolean;
http2_support: boolean;
hsts_enabled: boolean;
hsts_subdomains: boolean;
block_exploits: boolean;
websocket_support: boolean;
locations: Location[];
advanced_config?: string;
enabled: boolean;
created_at: string;
updated_at: string;
}
export const getProxyHosts = async (): Promise<ProxyHost[]> => {
const { data } = await client.get<ProxyHost[]>('/proxy-hosts');
return data;
};
export const getProxyHost = async (uuid: string): Promise<ProxyHost> => {
const { data } = await client.get<ProxyHost>(`/proxy-hosts/${uuid}`);
return data;
};
export const createProxyHost = async (host: Partial<ProxyHost>): Promise<ProxyHost> => {
const { data } = await client.post<ProxyHost>('/proxy-hosts', host);
return data;
};
export const updateProxyHost = async (uuid: string, host: Partial<ProxyHost>): Promise<ProxyHost> => {
const { data } = await client.put<ProxyHost>(`/proxy-hosts/${uuid}`, host);
return data;
};
export const deleteProxyHost = async (uuid: string): Promise<void> => {
await client.delete(`/proxy-hosts/${uuid}`);
};
+40
View File
@@ -0,0 +1,40 @@
import client from './client';
export interface RemoteServer {
uuid: string;
name: string;
provider: string;
host: string;
port: number;
username?: string;
enabled: boolean;
reachable: boolean;
last_check?: string;
created_at: string;
updated_at: string;
}
export const getRemoteServers = async (enabledOnly = false): Promise<RemoteServer[]> => {
const params = enabledOnly ? { enabled: true } : {};
const { data } = await client.get<RemoteServer[]>('/remote-servers', { params });
return data;
};
export const getRemoteServer = async (uuid: string): Promise<RemoteServer> => {
const { data } = await client.get<RemoteServer>(`/remote-servers/${uuid}`);
return data;
};
export const createRemoteServer = async (server: Partial<RemoteServer>): Promise<RemoteServer> => {
const { data } = await client.post<RemoteServer>('/remote-servers', server);
return data;
};
export const updateRemoteServer = async (uuid: string, server: Partial<RemoteServer>): Promise<RemoteServer> => {
const { data } = await client.put<RemoteServer>(`/remote-servers/${uuid}`, server);
return data;
};
export const deleteRemoteServer = async (uuid: string): Promise<void> => {
await client.delete(`/remote-servers/${uuid}`);
};
+21 -35
View File
@@ -1,43 +1,29 @@
interface ImportBannerProps {
session: {
uuid: string
filename?: string
state: string
created_at: string
}
interface Props {
session: { id: string }
onReview: () => void
onCancel: () => void
}
export default function ImportBanner({ session, onReview, onCancel }: ImportBannerProps) {
export default function ImportBanner({ session, onReview, onCancel }: Props) {
return (
<div className="bg-blue-900/20 border border-blue-500 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-blue-400 mb-1">
Import Session Active
</h3>
<p className="text-sm text-gray-300">
{session.filename && `File: ${session.filename}`}
State: <span className="font-medium">{session.state}</span>
</p>
</div>
<div className="flex gap-3">
{session.state === 'reviewing' && (
<button
onClick={onReview}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors"
>
Review Changes
</button>
)}
<button
onClick={onCancel}
className="px-4 py-2 bg-red-900/20 hover:bg-red-900/30 text-red-400 rounded-lg font-medium transition-colors"
>
Cancel Import
</button>
</div>
<div className="bg-yellow-900/20 border border-yellow-600 text-yellow-300 px-4 py-3 rounded mb-6 flex items-center justify-between">
<div>
<div className="font-medium">Pending Import Session</div>
<div className="text-sm text-yellow-400/80">Session ID: {session.id}</div>
</div>
<div className="flex gap-3">
<button
onClick={onReview}
className="px-3 py-1 bg-yellow-600 hover:bg-yellow-500 text-black rounded text-sm font-medium"
>
Review Changes
</button>
<button
onClick={onCancel}
className="px-3 py-1 bg-gray-800 hover:bg-gray-700 text-yellow-300 border border-yellow-700 rounded text-sm font-medium"
>
Cancel
</button>
</div>
</div>
)
+98 -148
View File
@@ -1,171 +1,121 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
interface ImportReviewTableProps {
hosts: any[]
conflicts: string[]
interface HostPreview {
domain_names: string
[key: string]: unknown
}
interface Props {
hosts: HostPreview[]
conflicts: Record<string, string>
errors: string[]
onCommit: (resolutions: Record<string, string>) => Promise<void>
onCancel: () => void
}
export default function ImportReviewTable({ hosts, conflicts, errors, onCommit, onCancel }: ImportReviewTableProps) {
const [resolutions, setResolutions] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(false)
const hasConflicts = conflicts.length > 0
const handleResolutionChange = (domain: string, action: string) => {
setResolutions({ ...resolutions, [domain]: action })
}
export default function ImportReviewTable({ hosts, conflicts, errors, onCommit, onCancel }: Props) {
const conflictDomains = useMemo(() => Object.keys(conflicts || {}), [conflicts])
const [resolutions, setResolutions] = useState<Record<string, string>>(() => {
const init: Record<string, string> = {}
conflictDomains.forEach((d: string) => { init[d] = conflicts[d] || 'keep' })
return init
})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleCommit = async () => {
// Ensure all conflicts have resolutions
const unresolvedConflicts = conflicts.filter(c => !resolutions[c])
if (unresolvedConflicts.length > 0) {
alert(`Please resolve all conflicts: ${unresolvedConflicts.join(', ')}`)
return
}
setLoading(true)
setSubmitting(true)
setError(null)
try {
await onCommit(resolutions)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to commit import')
} finally {
setLoading(false)
setSubmitting(false)
}
}
return (
<div className="space-y-6">
{/* Errors */}
{errors.length > 0 && (
<div className="bg-red-900/20 border border-red-500 rounded-lg p-4">
<h3 className="text-lg font-semibold text-red-400 mb-2">Errors</h3>
<ul className="list-disc list-inside space-y-1">
{errors.map((error, idx) => (
<li key={idx} className="text-sm text-red-300">{error}</li>
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="p-4 border-b border-gray-800 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">Review Imported Hosts</h2>
<div className="flex gap-3">
<button
onClick={onCancel}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors"
>
Back
</button>
<button
onClick={handleCommit}
disabled={submitting}
className="px-4 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{submitting ? 'Committing...' : 'Commit Import'}
</button>
</div>
</div>
{error && (
<div className="m-4 bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
{error}
</div>
)}
{errors?.length > 0 && (
<div className="m-4 bg-yellow-900/20 border border-yellow-600 text-yellow-300 px-4 py-3 rounded">
<div className="font-medium mb-2">Issues found during parsing</div>
<ul className="list-disc list-inside text-sm">
{errors.map((e, i) => (
<li key={i}>{e}</li>
))}
</ul>
</div>
)}
{/* Conflicts */}
{hasConflicts && (
<div className="bg-yellow-900/20 border border-yellow-500 rounded-lg p-4">
<h3 className="text-lg font-semibold text-yellow-400 mb-2">
Conflicts Detected ({conflicts.length})
</h3>
<p className="text-sm text-gray-300 mb-4">
The following domains already exist. Choose how to handle each conflict:
</p>
<div className="space-y-3">
{conflicts.map((domain) => (
<div key={domain} className="flex items-center justify-between bg-gray-900 p-3 rounded">
<span className="text-white font-medium">{domain}</span>
<select
value={resolutions[domain] || ''}
onChange={e => handleResolutionChange(domain, e.target.value)}
className="bg-gray-800 border border-gray-700 rounded px-3 py-1 text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">-- Choose action --</option>
<option value="skip">Skip (keep existing)</option>
<option value="overwrite">Overwrite existing</option>
</select>
</div>
))}
</div>
</div>
)}
{/* Preview Hosts */}
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="px-6 py-4 bg-gray-900 border-b border-gray-800">
<h3 className="text-lg font-semibold text-white">
Hosts to Import ({hosts.length})
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Domain
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Forward To
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
SSL
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Features
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{hosts.map((host, idx) => {
const isConflict = conflicts.includes(host.domain_names)
return (
<tr key={idx} className={`hover:bg-gray-900/50 ${isConflict ? 'bg-yellow-900/10' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">{host.domain_names}</span>
{isConflict && (
<span className="px-2 py-1 text-xs bg-yellow-900/30 text-yellow-400 rounded">
Conflict
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-300">
{host.forward_scheme}://{host.forward_host}:{host.forward_port}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{host.ssl_forced && (
<span className="px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
SSL
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex gap-2">
{host.http2_support && (
<span className="px-2 py-1 text-xs bg-blue-900/30 text-blue-400 rounded">
HTTP/2
</span>
)}
{host.websocket_support && (
<span className="px-2 py-1 text-xs bg-purple-900/30 text-purple-400 rounded">
WS
</span>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
disabled={loading}
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleCommit}
disabled={loading || (hasConflicts && Object.keys(resolutions).length < conflicts.length)}
className="px-6 py-2 bg-blue-active hover:bg-blue-hover text-white rounded-lg font-medium transition-colors disabled:opacity-50"
>
{loading ? 'Importing...' : 'Commit Import'}
</button>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-900 border-b border-gray-800">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Domain Names
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
Conflict Resolution
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{hosts.map((h, idx) => {
const domain = h.domain_names
const hasConflict = conflictDomains.includes(domain)
return (
<tr key={`${domain}-${idx}`} className="hover:bg-gray-900/50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-white">{domain}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{hasConflict ? (
<select
value={resolutions[domain]}
onChange={e => setResolutions({ ...resolutions, [domain]: e.target.value })}
className="bg-gray-900 border border-gray-700 text-white rounded px-2 py-1"
>
<option value="keep">Keep Existing</option>
<option value="overwrite">Overwrite</option>
<option value="skip">Skip</option>
</select>
) : (
<span className="px-2 py-1 text-xs bg-green-900/30 text-green-400 rounded">
No conflict
</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
+4 -25
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { ProxyHost } from '../hooks/useProxyHosts'
import { remoteServersAPI } from '../services/api'
import { useState } from 'react'
import type { ProxyHost } from '../api/proxyHosts'
import { useRemoteServers } from '../hooks/useRemoteServers'
interface ProxyHostFormProps {
host?: ProxyHost
@@ -8,15 +8,6 @@ interface ProxyHostFormProps {
onCancel: () => void
}
interface RemoteServer {
uuid: string
name: string
provider: string
host: string
port: number
enabled: boolean
}
export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFormProps) {
const [formData, setFormData] = useState({
domain_names: host?.domain_names || '',
@@ -33,22 +24,10 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
enabled: host?.enabled ?? true,
})
const [remoteServers, setRemoteServers] = useState<RemoteServer[]>([])
const { servers: remoteServers } = useRemoteServers()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchServers = async () => {
try {
const servers = await remoteServersAPI.list(true)
setRemoteServers(servers)
} catch (err) {
console.error('Failed to fetch remote servers:', err)
}
}
fetchServers()
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
+44 -100
View File
@@ -1,59 +1,49 @@
import { useState } from 'react'
import { RemoteServer } from '../hooks/useRemoteServers'
import { remoteServersAPI } from '../services/api'
import { useEffect, useState } from 'react'
import type { RemoteServer } from '../api/remoteServers'
interface RemoteServerFormProps {
interface Props {
server?: RemoteServer
onSubmit: (data: Partial<RemoteServer>) => Promise<void>
onCancel: () => void
}
export default function RemoteServerForm({ server, onSubmit, onCancel }: RemoteServerFormProps) {
export default function RemoteServerForm({ server, onSubmit, onCancel }: Props) {
const [formData, setFormData] = useState({
name: server?.name || '',
provider: server?.provider || 'generic',
host: server?.host || '',
port: server?.port || 80,
port: server?.port ?? 22,
username: server?.username || '',
enabled: server?.enabled ?? true,
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [testResult, setTestResult] = useState<any | null>(null)
const [testing, setTesting] = useState(false)
useEffect(() => {
setFormData({
name: server?.name || '',
provider: server?.provider || 'generic',
host: server?.host || '',
port: server?.port ?? 22,
username: server?.username || '',
enabled: server?.enabled ?? true,
})
}, [server])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
await onSubmit(formData)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save remote server')
setError(err instanceof Error ? err.message : 'Failed to save server')
} finally {
setLoading(false)
}
}
const handleTestConnection = async () => {
if (!server) return
setTesting(true)
setTestResult(null)
setError(null)
try {
const result = await remoteServersAPI.test(server.uuid)
setTestResult(result)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to test connection')
} finally {
setTesting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-dark-card rounded-lg border border-gray-800 max-w-lg w-full">
@@ -63,7 +53,7 @@ export default function RemoteServerForm({ server, onSubmit, onCancel }: RemoteS
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-900/20 border border-red-500 text-red-400 px-4 py-3 rounded">
{error}
@@ -77,28 +67,23 @@ export default function RemoteServerForm({ server, onSubmit, onCancel }: RemoteS
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="My Production Server"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Provider</label>
<select
value={formData.provider}
onChange={e => setFormData({ ...formData, provider: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="generic">Generic</option>
<option value="docker">Docker</option>
<option value="kubernetes">Kubernetes</option>
<option value="aws">AWS</option>
<option value="gcp">GCP</option>
<option value="azure">Azure</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Provider</label>
<select
value={formData.provider}
onChange={e => setFormData({ ...formData, provider: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="generic">Generic</option>
<option value="docker">Docker</option>
<option value="kubernetes">Kubernetes</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Host</label>
<input
@@ -110,31 +95,29 @@ export default function RemoteServerForm({ server, onSubmit, onCancel }: RemoteS
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Port</label>
<input
type="number"
required
min="1"
max="65535"
min={1}
max={65535}
value={formData.port}
onChange={e => setFormData({ ...formData, port: parseInt(e.target.value) })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Username (Optional)
</label>
<input
type="text"
value={formData.username}
onChange={e => setFormData({ ...formData, username: e.target.value })}
placeholder="admin"
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Username</label>
<input
type="text"
value={formData.username}
onChange={e => setFormData({ ...formData, username: e.target.value })}
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<label className="flex items-center gap-3">
@@ -147,45 +130,6 @@ export default function RemoteServerForm({ server, onSubmit, onCancel }: RemoteS
<span className="text-sm text-gray-300">Enabled</span>
</label>
{/* Connection Test */}
{server && (
<div className="pt-4 border-t border-gray-800">
<button
type="button"
onClick={handleTestConnection}
disabled={testing}
className="w-full px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{testing ? (
<>
<span className="animate-spin"></span>
Testing Connection...
</>
) : (
<>
<span>🔌</span>
Test Connection
</>
)}
</button>
{testResult && (
<div className={`mt-3 p-3 rounded-lg ${testResult.reachable ? 'bg-green-900/20 border border-green-500' : 'bg-red-900/20 border border-red-500'}`}>
<div className="flex items-center gap-2">
<span className={testResult.reachable ? 'text-green-400' : 'text-red-400'}>
{testResult.reachable ? '✓ Connection Successful' : '✗ Connection Failed'}
</span>
</div>
{testResult.error && (
<div className="text-xs text-red-300 mt-1">{testResult.error}</div>
)}
{testResult.address && (
<div className="text-xs text-gray-400 mt-1">Address: {testResult.address}</div>
)}
</div>
)}
</div>
)}
<div className="flex gap-3 justify-end pt-4 border-t border-gray-800">
<button
type="button"
+64 -104
View File
@@ -1,116 +1,76 @@
import { useState, useEffect, useCallback } from 'react'
import { importAPI } from '../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
uploadCaddyfile,
getImportPreview,
commitImport,
cancelImport,
getImportStatus,
ImportSession,
ImportPreview
} from '../api/import';
interface ImportSession {
uuid: string
filename?: string
state: string
created_at: string
updated_at: string
}
interface ImportPreview {
hosts: any[]
conflicts: string[]
errors: string[]
}
export const QUERY_KEY = ['import-session'];
export function useImport() {
const [session, setSession] = useState<ImportSession | null>(null)
const [preview, setPreview] = useState<ImportPreview | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [polling, setPolling] = useState(false)
const queryClient = useQueryClient();
const checkStatus = useCallback(async () => {
try {
const status = await importAPI.status()
if (status.has_pending && status.session) {
setSession(status.session)
if (status.session.state === 'reviewing') {
const previewData = await importAPI.preview()
setPreview(previewData)
}
} else {
setSession(null)
setPreview(null)
// Poll for status if we think there's an active session
const statusQuery = useQuery({
queryKey: QUERY_KEY,
queryFn: getImportStatus,
refetchInterval: (query) => {
const data = query.state.data;
// Poll if we have a pending session in reviewing state
if (data?.has_pending && data?.session?.state === 'reviewing') {
return 3000;
}
} catch (err) {
console.error('Failed to check import status:', err)
}
}, [])
return false;
},
});
useEffect(() => {
checkStatus()
}, [checkStatus])
const previewQuery = useQuery({
queryKey: ['import-preview'],
queryFn: getImportPreview,
enabled: !!statusQuery.data?.has_pending && statusQuery.data?.session?.state === 'reviewing',
});
useEffect(() => {
if (polling && session?.state === 'reviewing') {
const interval = setInterval(checkStatus, 3000)
return () => clearInterval(interval)
}
}, [polling, session?.state, checkStatus])
const uploadMutation = useMutation({
mutationFn: uploadCaddyfile,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
queryClient.invalidateQueries({ queryKey: ['import-preview'] });
},
});
const upload = async (content: string, filename?: string) => {
try {
setLoading(true)
setError(null)
const result = await importAPI.upload(content, filename)
setSession(result.session)
setPolling(true)
await checkStatus()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to upload Caddyfile')
throw err
} finally {
setLoading(false)
}
}
const commitMutation = useMutation({
mutationFn: commitImport,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
queryClient.invalidateQueries({ queryKey: ['import-preview'] });
// Also invalidate proxy hosts as they might have changed
queryClient.invalidateQueries({ queryKey: ['proxy-hosts'] });
},
});
const commit = async (resolutions: Record<string, string>) => {
if (!session) throw new Error('No active session')
try {
setLoading(true)
setError(null)
await importAPI.commit(session.uuid, resolutions)
setSession(null)
setPreview(null)
setPolling(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to commit import')
throw err
} finally {
setLoading(false)
}
}
const cancel = async () => {
if (!session) return
try {
setLoading(true)
setError(null)
await importAPI.cancel(session.uuid)
setSession(null)
setPreview(null)
setPolling(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to cancel import')
throw err
} finally {
setLoading(false)
}
}
const cancelMutation = useMutation({
mutationFn: cancelImport,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
queryClient.invalidateQueries({ queryKey: ['import-preview'] });
},
});
return {
session,
preview,
loading,
error,
upload,
commit,
cancel,
refresh: checkStatus,
}
session: statusQuery.data?.session || null,
preview: previewQuery.data || null,
loading: statusQuery.isLoading || uploadMutation.isPending || commitMutation.isPending || cancelMutation.isPending,
error: (statusQuery.error || previewQuery.error || uploadMutation.error || commitMutation.error || cancelMutation.error)
? ((statusQuery.error || previewQuery.error || uploadMutation.error || commitMutation.error || cancelMutation.error) as Error).message
: null,
upload: uploadMutation.mutateAsync,
commit: commitMutation.mutateAsync,
cancel: cancelMutation.mutateAsync,
};
}
export type { ImportSession, ImportPreview };
+45 -74
View File
@@ -1,84 +1,55 @@
import { useState, useEffect } from 'react'
import { proxyHostsAPI } from '../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getProxyHosts,
createProxyHost,
updateProxyHost,
deleteProxyHost,
ProxyHost
} from '../api/proxyHosts';
export interface ProxyHost {
uuid: string
domain_names: string
forward_scheme: string
forward_host: string
forward_port: number
access_list_id?: string
certificate_id?: string
ssl_forced: boolean
http2_support: boolean
hsts_enabled: boolean
hsts_subdomains: boolean
block_exploits: boolean
websocket_support: boolean
advanced_config?: string
enabled: boolean
created_at: string
updated_at: string
}
export const QUERY_KEY = ['proxy-hosts'];
export function useProxyHosts() {
const [hosts, setHosts] = useState<ProxyHost[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const queryClient = useQueryClient();
const fetchHosts = async () => {
try {
setLoading(true)
setError(null)
const data = await proxyHostsAPI.list()
setHosts(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch proxy hosts')
} finally {
setLoading(false)
}
}
const query = useQuery({
queryKey: QUERY_KEY,
queryFn: getProxyHosts,
});
useEffect(() => {
fetchHosts()
}, [])
const createMutation = useMutation({
mutationFn: createProxyHost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const createHost = async (data: Partial<ProxyHost>) => {
try {
const newHost = await proxyHostsAPI.create(data)
setHosts([...hosts, newHost])
return newHost
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to create proxy host')
}
}
const updateMutation = useMutation({
mutationFn: ({ uuid, data }: { uuid: string; data: Partial<ProxyHost> }) =>
updateProxyHost(uuid, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const updateHost = async (uuid: string, data: Partial<ProxyHost>) => {
try {
const updatedHost = await proxyHostsAPI.update(uuid, data)
setHosts(hosts.map(h => h.uuid === uuid ? updatedHost : h))
return updatedHost
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to update proxy host')
}
}
const deleteHost = async (uuid: string) => {
try {
await proxyHostsAPI.delete(uuid)
setHosts(hosts.filter(h => h.uuid !== uuid))
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to delete proxy host')
}
}
const deleteMutation = useMutation({
mutationFn: deleteProxyHost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
return {
hosts,
loading,
error,
refresh: fetchHosts,
createHost,
updateHost,
deleteHost,
}
hosts: query.data || [],
loading: query.isLoading,
error: query.error ? (query.error as Error).message : null,
createHost: createMutation.mutateAsync,
updateHost: (uuid: string, data: Partial<ProxyHost>) => updateMutation.mutateAsync({ uuid, data }),
deleteHost: deleteMutation.mutateAsync,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
};
}
export type { ProxyHost };
+46 -81
View File
@@ -1,90 +1,55 @@
import { useState, useEffect } from 'react'
import { remoteServersAPI } from '../services/api'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getRemoteServers,
createRemoteServer,
updateRemoteServer,
deleteRemoteServer,
RemoteServer
} from '../api/remoteServers';
export interface RemoteServer {
uuid: string
name: string
provider: string
host: string
port: number
username?: string
enabled: boolean
reachable: boolean
last_check?: string
created_at: string
updated_at: string
}
export const QUERY_KEY = ['remote-servers'];
export function useRemoteServers() {
const [servers, setServers] = useState<RemoteServer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
export function useRemoteServers(enabledOnly = false) {
const queryClient = useQueryClient();
const fetchServers = async (enabledOnly = false) => {
try {
setLoading(true)
setError(null)
const data = await remoteServersAPI.list(enabledOnly)
setServers(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch remote servers')
} finally {
setLoading(false)
}
}
const query = useQuery({
queryKey: [...QUERY_KEY, { enabled: enabledOnly }],
queryFn: () => getRemoteServers(enabledOnly),
});
useEffect(() => {
fetchServers()
}, [])
const createMutation = useMutation({
mutationFn: createRemoteServer,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const createServer = async (data: Partial<RemoteServer>) => {
try {
const newServer = await remoteServersAPI.create(data)
setServers([...servers, newServer])
return newServer
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to create remote server')
}
}
const updateMutation = useMutation({
mutationFn: ({ uuid, data }: { uuid: string; data: Partial<RemoteServer> }) =>
updateRemoteServer(uuid, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const updateServer = async (uuid: string, data: Partial<RemoteServer>) => {
try {
const updatedServer = await remoteServersAPI.update(uuid, data)
setServers(servers.map(s => s.uuid === uuid ? updatedServer : s))
return updatedServer
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to update remote server')
}
}
const deleteServer = async (uuid: string) => {
try {
await remoteServersAPI.delete(uuid)
setServers(servers.filter(s => s.uuid !== uuid))
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to delete remote server')
}
}
const testConnection = async (uuid: string) => {
try {
return await remoteServersAPI.test(uuid)
} catch (err) {
throw new Error(err instanceof Error ? err.message : 'Failed to test connection')
}
}
const enabledServers = servers.filter(s => s.enabled)
const deleteMutation = useMutation({
mutationFn: deleteRemoteServer,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
return {
servers,
enabledServers,
loading,
error,
refresh: fetchServers,
createServer,
updateServer,
deleteServer,
testConnection,
}
servers: query.data || [],
loading: query.isLoading,
error: query.error ? (query.error as Error).message : null,
createServer: createMutation.mutateAsync,
updateServer: (uuid: string, data: Partial<RemoteServer>) => updateMutation.mutateAsync({ uuid, data }),
deleteServer: deleteMutation.mutateAsync,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
};
}
export type { RemoteServer };
+4 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useProxyHosts } from '../hooks/useProxyHosts'
import { useRemoteServers } from '../hooks/useRemoteServers'
import { healthAPI } from '../services/api'
import { checkHealth } from '../api/health'
import { Link } from 'react-router-dom'
export default function Dashboard() {
@@ -10,15 +10,15 @@ export default function Dashboard() {
const [health, setHealth] = useState<{ status: string } | null>(null)
useEffect(() => {
const checkHealth = async () => {
const fetchHealth = async () => {
try {
const result = await healthAPI.check()
const result = await checkHealth()
setHealth(result)
} catch (err) {
setHealth({ status: 'error' })
}
}
checkHealth()
fetchHealth()
}, [])
const enabledHosts = hosts.filter(h => h.enabled).length
+4 -4
View File
@@ -131,11 +131,11 @@ api.example.com {
</div>
)}
{showReview && preview && (
{showReview && preview && preview.preview && (
<ImportReviewTable
hosts={preview.hosts}
conflicts={preview.conflicts}
errors={preview.errors}
hosts={preview.preview.hosts}
conflicts={preview.preview.conflicts}
errors={preview.preview.errors}
onCommit={handleCommit}
onCancel={() => setShowReview(false)}
/>
+2 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { useProxyHosts, ProxyHost } from '../hooks/useProxyHosts'
import { useProxyHosts } from '../hooks/useProxyHosts'
import type { ProxyHost } from '../api/proxyHosts'
import ProxyHostForm from '../components/ProxyHostForm'
export default function ProxyHosts() {
+2 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { useRemoteServers, RemoteServer } from '../hooks/useRemoteServers'
import { useRemoteServers } from '../hooks/useRemoteServers'
import type { RemoteServer } from '../api/remoteServers'
import RemoteServerForm from '../components/RemoteServerForm'
export default function RemoteServers() {
-21
View File
@@ -1,21 +0,0 @@
# Development requirements
# Testing, linters, formatting, and security checks
# pytest-xdist is not used - tests run serially to reproduce a user's experience more accurately.
pytest>=7.4.4
pytest-cov>=4.1
black>=24.10.0
ruff>=0.6.0
isort>=5.13.2
mypy>=1.6
pre-commit>=3.4
bandit>=1.9.1
tox>=4.11
pytest-timeout==2.4.0
# Add more dev tools as required
# Coverage tooling and additional linters
coverage>=7.12.0
flake8>=6.1
-15
View File
@@ -1,15 +0,0 @@
# Base runtime requirements - adapt to your stack.
# Example for a Python FastAPI backend. Remove or replace if using Go/Node/etc.
fastapi>=0.121.2
uvicorn[standard]>=0.22.0
pydantic>=2.0
sqlalchemy>=2.0.44
alembic>=1.17.2
python-dotenv>=1.0
passlib[bcrypt]>=1.7.4
httpx>=0.28.1
requests>=2.31
python-multipart>=0.0.20
# Add additional runtime libs below