Merge pull request #109 from Wikid82/feature/prox_host_managment
Refactor: Migrate Frontend to React Query & Clean Architecture
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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}" \
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
-1
File diff suppressed because one or more lines are too long
-74
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user