Update to memory saves. Added endpoint mangement per API

This commit is contained in:
seniorswe
2025-09-22 20:53:33 -04:00
committed by seniorswe
parent 964ed064d6
commit 849cba2fac
16 changed files with 978 additions and 205 deletions

View File

@@ -57,10 +57,10 @@ REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
# Mem Cache Config
CACHE_DUMP_INTERVAL=300
CACHE_MIN_DUMP_INTERVAL=60
CACHE_DUMP_FILE=data/doorman_data.enc
# Memory Dump Config (memory-only mode)
# Base path/stem for encrypted in-memory database dumps (.bin). Timestamp is appended.
# Example produces files like generated/memory_dump-YYYYMMDDTHHMMSSZ.bin
MEM_DUMP_PATH=generated/memory_dump.bin
# Authorization Config
JWT_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -137,4 +137,4 @@ Use at your own risk. By using this software, you agree to the [Apache 2.0 Licen
##
We welcome contributors and testers!
We welcome contributors and testers!

View File

@@ -49,6 +49,7 @@ import sys
import subprocess
import signal
import uvicorn
import time
import asyncio
from utils.response_util import process_response
@@ -187,6 +188,32 @@ async def startup_event():
except Exception as e:
gateway_logger.error(f"Memory mode restore failed: {e}")
# Register SIGUSR1 handler to force a memory dump (Unix only)
try:
if hasattr(signal, "SIGUSR1"):
loop = asyncio.get_event_loop()
async def _sigusr1_dump():
try:
if not database.memory_only:
gateway_logger.info("SIGUSR1 ignored: not in memory-only mode")
return
if not os.getenv("MEM_ENCRYPTION_KEY"):
gateway_logger.error("SIGUSR1 dump skipped: MEM_ENCRYPTION_KEY not configured")
return
settings = get_cached_settings()
path_hint = settings.get("dump_path")
dump_path = await asyncio.to_thread(dump_memory_to_file, path_hint)
gateway_logger.info(f"SIGUSR1: memory dump written to {dump_path}")
except Exception as e:
gateway_logger.error(f"SIGUSR1 dump failed: {e}")
loop.add_signal_handler(signal.SIGUSR1, lambda: asyncio.create_task(_sigusr1_dump()))
gateway_logger.info("SIGUSR1 handler registered for on-demand memory dumps")
except NotImplementedError:
# add_signal_handler not supported on this platform/event loop
pass
@doorman.on_event("shutdown")
async def shutdown_event():
# Stop auto-save task cleanly
@@ -265,9 +292,6 @@ def start():
gateway_logger.info(f"Starting doorman with PID {process.pid}.")
def stop():
if doorman_cache.cache_type == "MEM":
doorman_cache.force_save_cache()
doorman_cache.stop_cache_persistence()
if not os.path.exists(PID_FILE):
gateway_logger.info("No running instance found")
return
@@ -277,7 +301,18 @@ def stop():
if os.name == "nt":
subprocess.call(["taskkill", "/F", "/PID", str(pid)])
else:
# Send SIGTERM to allow graceful shutdown; FastAPI shutdown event
# writes a final encrypted memory dump in memory-only mode.
os.killpg(pid, signal.SIGTERM)
# Wait briefly for graceful shutdown so the dump can complete
deadline = time.time() + 15
while time.time() < deadline:
try:
# Check if process group leader still exists
os.kill(pid, 0)
time.sleep(0.5)
except ProcessLookupError:
break
print(f"Stopping doorman with PID {pid}")
except ProcessLookupError:
print("Process already terminated")

View File

@@ -5,7 +5,7 @@ See https://github.com/pypeople-dev/doorman for more information
"""
from pydantic import BaseModel, Field
from typing import Optional
from typing import Optional, List
class CreateEndpointModel(BaseModel):
@@ -14,9 +14,10 @@ class CreateEndpointModel(BaseModel):
endpoint_method: str = Field(..., min_length=1, max_length=10, description="HTTP method for the endpoint", example="GET")
endpoint_uri: str = Field(..., min_length=1, max_length=255, description="URI for the endpoint", example="/customer")
endpoint_description: str = Field(..., min_length=1, max_length=255, description="Description of the endpoint", example="Get customer details")
endpoint_servers: Optional[List[str]] = Field(None, description="Optional list of backend servers for this endpoint (overrides API servers)", example=["http://localhost:8082", "http://localhost:8083"])
api_id: Optional[str] = Field(None, description="Unique identifier for the API, auto-generated", example=None)
endpoint_id: Optional[str] = Field(None, description="Unique identifier for the endpoint, auto-generated", example=None)
class Config:
arbitrary_types_allowed = True
arbitrary_types_allowed = True

View File

@@ -5,7 +5,7 @@ See https://github.com/pypeople-dev/doorman for more information
"""
from pydantic import BaseModel, Field
from typing import Optional
from typing import Optional, List
class EndpointModelResponse(BaseModel):
@@ -14,8 +14,9 @@ class EndpointModelResponse(BaseModel):
endpoint_method: Optional[str] = Field(None, min_length=1, max_length=10, description="HTTP method for the endpoint", example="GET")
endpoint_uri: Optional[str] = Field(None, min_length=1, max_length=255, description="URI for the endpoint", example="/customer")
endpoint_description: Optional[str] = Field(None, min_length=1, max_length=255, description="Description of the endpoint", example="Get customer details")
endpoint_servers: Optional[List[str]] = Field(None, description="Optional list of backend servers for this endpoint (overrides API servers)", example=["http://localhost:8082", "http://localhost:8083"])
api_id: Optional[str] = Field(None, min_length=1, max_length=255, description="Unique identifier for the API, auto-generated", example=None)
endpoint_id: Optional[str] = Field(None, min_length=1, max_length=255, description="Unique identifier for the endpoint, auto-generated", example=None)
class Config:
arbitrary_types_allowed = True
arbitrary_types_allowed = True

View File

@@ -5,7 +5,7 @@ See https://github.com/pypeople-dev/doorman for more information
"""
from pydantic import BaseModel, Field
from typing import Optional
from typing import Optional, List
class UpdateEndpointModel(BaseModel):
@@ -14,8 +14,9 @@ class UpdateEndpointModel(BaseModel):
endpoint_method: Optional[str] = Field(None, min_length=1, max_length=10, description="HTTP method for the endpoint", example="GET")
endpoint_uri: Optional[str] = Field(None, min_length=1, max_length=255, description="URI for the endpoint", example="/customer")
endpoint_description: Optional[str] = Field(None, min_length=1, max_length=255, description="Description of the endpoint", example="Get customer details")
endpoint_servers: Optional[List[str]] = Field(None, description="Optional list of backend servers for this endpoint (overrides API servers)", example=["http://localhost:8082", "http://localhost:8083"])
api_id: Optional[str] = Field(None, min_length=1, max_length=255, description="Unique identifier for the API, auto-generated", example=None)
endpoint_id: Optional[str] = Field(None, min_length=1, max_length=255, description="Unique identifier for the endpoint, auto-generated", example=None)
class Config:
arbitrary_types_allowed = True
arbitrary_types_allowed = True

View File

@@ -93,17 +93,10 @@ class GatewayService:
logger.error(f"{endpoints} | REST gateway failed with code GTW003")
return GatewayService.error_response(request_id, 'GTW003', 'Endpoint does not exist for the requested API')
client_key = request.headers.get('client-key')
if client_key:
server = await routing_util.get_routing_info(client_key)
if not server:
return GatewayService.error_response(request_id, 'GTW007', 'Client key does not exist in routing')
logger.info(f"{request_id} | REST gateway to: {server}")
else:
server_index = doorman_cache.get_cache('endpoint_server_cache', api.get('api_id')) or 0
api_servers = api.get('api_servers') or []
server = api_servers[server_index]
doorman_cache.set_cache('endpoint_server_cache', api.get('api_id'), (server_index + 1) % len(api_servers))
logger.info(f"{request_id} | REST gateway to: {server}")
server = await routing_util.pick_upstream_server(api, request.method, endpoint_uri, client_key)
if not server:
return GatewayService.error_response(request_id, 'GTW001', 'No upstream servers configured')
logger.info(f"{request_id} | REST gateway to: {server}")
url = server.rstrip('/') + '/' + endpoint_uri.lstrip('/')
method = request.method.upper()
retry = api.get('api_allowed_retry_count') or 0
@@ -213,15 +206,9 @@ class GatewayService:
if not any(re.fullmatch(regex_pattern.sub(r"([^/]+)", ep), composite) for ep in endpoints):
return GatewayService.error_response(request_id, 'GTW003', 'Endpoint does not exist for the requested API')
client_key = request.headers.get('client-key')
if client_key:
server = await routing_util.get_routing_info(client_key)
if not server:
return GatewayService.error_response(request_id, 'GTW007', 'Client key does not exist in routing')
else:
server_index = doorman_cache.get_cache('endpoint_server_cache', api.get('api_id')) or 0
api_servers = api.get('api_servers') or []
server = api_servers[server_index]
doorman_cache.set_cache('endpoint_server_cache', api.get('api_id'), (server_index + 1) % len(api_servers))
server = await routing_util.pick_upstream_server(api, 'POST', endpoint_uri, client_key)
if not server:
return GatewayService.error_response(request_id, 'GTW001', 'No upstream servers configured')
url = server.rstrip('/') + '/' + endpoint_uri.lstrip('/')
logger.info(f"{request_id} | SOAP gateway to: {url}")
retry = api.get('api_allowed_retry_count') or 0
@@ -302,10 +289,12 @@ class GatewayService:
logger.error(f"{request_id} | API not found: {api_path}")
return GatewayService.error_response(request_id, 'GTW001', f'API does not exist: {api_path}')
doorman_cache.set_cache('api_cache', api_path, api)
if not api.get('api_servers'):
logger.error(f"{request_id} | No API servers configured for {api_path}")
return GatewayService.error_response(request_id, 'GTW001', 'No API servers configured')
url = api.get('api_servers', [])[0].rstrip('/')
client_key = request.headers.get('client-key')
server = await routing_util.pick_upstream_server(api, 'POST', '/graphql', client_key)
if not server:
logger.error(f"{request_id} | No upstream servers configured for {api_path}")
return GatewayService.error_response(request_id, 'GTW001', 'No upstream servers configured')
url = server.rstrip('/')
retry = api.get('api_allowed_retry_count') or 0
if api.get('api_tokens_enabled'):
if not await token_util.deduct_ai_token(api.get('api_token_group'), username):
@@ -396,10 +385,12 @@ class GatewayService:
logger.error(f"{request_id} | API not found: {api_path}")
return GatewayService.error_response(request_id, 'GTW001', f'API does not exist: {api_path}', status=404)
doorman_cache.set_cache('api_cache', api_path, api)
if not api.get('api_servers'):
logger.error(f"{request_id} | No API servers configured for {api_path}")
return GatewayService.error_response(request_id, 'GTW001', 'No API servers configured', status=404)
url = api.get('api_servers', [])[0].rstrip('/')
client_key = request.headers.get('client-key')
server = await routing_util.pick_upstream_server(api, 'POST', '/grpc', client_key)
if not server:
logger.error(f"{request_id} | No upstream servers configured for {api_path}")
return GatewayService.error_response(request_id, 'GTW001', 'No upstream servers configured', status=404)
url = server.rstrip('/')
if url.startswith('grpc://'):
url = url[7:]
retry = api.get('api_allowed_retry_count') or 0
@@ -536,4 +527,4 @@ class GatewayService:
'message': f'Error making GraphQL request: {str(e)}',
'extensions': {'code': 'REQUEST_ERROR'}
}]
}
}

View File

@@ -1,5 +1,6 @@
from utils.doorman_cache_util import doorman_cache
from utils.database import api_collection, endpoint_collection
from typing import Optional, Dict
async def get_api(api_key, api_name_version):
api = doorman_cache.get_cache('api_cache', api_key) if api_key else None
@@ -25,4 +26,28 @@ async def get_api_endpoints(api_id):
for endpoint in endpoints_list
]
doorman_cache.set_cache('api_endpoint_cache', api_id, endpoints)
return endpoints
return endpoints
async def get_endpoint(api: Dict, method: str, endpoint_uri: str) -> Optional[Dict]:
"""Return the endpoint document for a given API, method, and uri.
Uses the same cache key pattern as EndpointService to avoid duplicate queries.
"""
api_name = api.get('api_name')
api_version = api.get('api_version')
cache_key = f"/{method}/{api_name}/{api_version}/{endpoint_uri}".replace("//", "/")
endpoint = doorman_cache.get_cache('endpoint_cache', cache_key)
if endpoint:
return endpoint
doc = endpoint_collection.find_one({
'api_name': api_name,
'api_version': api_version,
'endpoint_uri': endpoint_uri,
'endpoint_method': method
})
if not doc:
return None
doc.pop('_id', None)
doorman_cache.set_cache('endpoint_cache', cache_key, doc)
return doc

View File

@@ -9,27 +9,11 @@ import json
import os
import threading
from typing import Dict, Any, Optional
import pickle
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64
class MemoryCache:
def __init__(self):
self._cache: Dict[str, Dict[str, Any]] = {}
self._lock = threading.RLock()
self._dump_file = os.getenv("CACHE_DUMP_FILE", "cache_dump.enc")
self._encryption_key = self._get_encryption_key()
self._auto_save_thread = None
self._stop_auto_save = threading.Event()
self._dump_interval = int(os.getenv("CACHE_DUMP_INTERVAL", "300"))
self._min_dump_interval = int(os.getenv("CACHE_MIN_DUMP_INTERVAL", "60"))
self._last_dump_time = 0
self._cache_modified = False
self._last_cache_size = 0
self._load_cache()
self._start_auto_save()
def setex(self, key: str, ttl: int, value: str):
with self._lock:
@@ -37,8 +21,7 @@ class MemoryCache:
'value': value,
'expires_at': self._get_current_time() + ttl
}
self._cache_modified = True
def get(self, key: str) -> Optional[str]:
with self._lock:
if key in self._cache:
@@ -54,7 +37,6 @@ class MemoryCache:
for key in keys:
if key in self._cache:
self._cache.pop(key, None)
self._cache_modified = True
def keys(self, pattern: str) -> list:
with self._lock:
@@ -77,9 +59,7 @@ class MemoryCache:
return {
'total_entries': total_entries,
'active_entries': active_entries,
'expired_entries': expired_entries,
'dump_file': self._dump_file,
'auto_save_active': not self._stop_auto_save.is_set()
'expired_entries': expired_entries
}
def _cleanup_expired(self):
@@ -94,84 +74,9 @@ class MemoryCache:
if expired_keys:
print(f"Cleaned up {len(expired_keys)} expired cache entries")
def _get_encryption_key(self) -> bytes:
env_key = os.getenv("MEM_ENCRYPTION_KEY")
if not env_key:
raise ValueError("MEM_ENCRYPTION_KEY environment variable is required for memory cache")
salt = b'pygate_cache_salt'
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(env_key.encode()))
return key
def _encrypt_data(self, data: bytes) -> bytes:
f = Fernet(self._encryption_key)
return f.encrypt(data)
def _decrypt_data(self, encrypted_data: bytes) -> bytes:
f = Fernet(self._encryption_key)
return f.decrypt(encrypted_data)
def _save_cache(self):
try:
with self._lock:
cache_data = {}
current_time = self._get_current_time()
for key, entry in self._cache.items():
if current_time < entry['expires_at']:
cache_data[key] = entry
serialized_data = pickle.dumps(cache_data)
encrypted_data = self._encrypt_data(serialized_data)
temp_file = f"{self._dump_file}.tmp"
dump_dir = os.path.dirname(self._dump_file)
if dump_dir:
os.makedirs(dump_dir, exist_ok=True)
with open(temp_file, 'wb') as f:
f.write(encrypted_data)
os.replace(temp_file, self._dump_file)
except Exception as e:
print(f"Warning: Failed to save cache to {self._dump_file}: {e}")
def _load_cache(self):
try:
if os.path.exists(self._dump_file):
with open(self._dump_file, 'rb') as f:
encrypted_data = f.read()
decrypted_data = self._decrypt_data(encrypted_data)
loaded_cache = pickle.loads(decrypted_data)
current_time = self._get_current_time()
with self._lock:
for key, entry in loaded_cache.items():
if current_time < entry['expires_at']:
self._cache[key] = entry
print(f"Loaded {len(self._cache)} cache entries from {self._dump_file}")
except Exception as e:
print(f"Warning: Failed to load cache from {self._dump_file}: {e}")
def _start_auto_save(self):
def auto_save_worker():
while not self._stop_auto_save.wait(self._min_dump_interval):
current_time = self._get_current_time()
if (self._cache_modified and
current_time - self._last_dump_time >= self._dump_interval and
abs(len(self._cache) - self._last_cache_size) > 0):
self._save_cache()
self._last_dump_time = current_time
self._cache_modified = False
self._last_cache_size = len(self._cache)
self._auto_save_thread = threading.Thread(target=auto_save_worker, daemon=True)
self._auto_save_thread.start()
# No-op stubs to keep interface compatibility
def stop_auto_save(self):
"""Stop the auto-save thread and perform final save."""
self._stop_auto_save.set()
if self._auto_save_thread:
self._auto_save_thread.join(timeout=5)
self._save_cache()
return
class DoormanCacheManager:
def __init__(self):
@@ -284,13 +189,12 @@ class DoormanCacheManager:
self.cache._cleanup_expired()
def force_save_cache(self):
if not self.is_redis and hasattr(self.cache, '_save_cache'):
self.cache._save_cache()
# No-op: cache persistence removed
return
def stop_cache_persistence(self):
"""Stop the auto-save thread (memory cache only)."""
if not self.is_redis and hasattr(self.cache, 'stop_auto_save'):
self.cache.stop_auto_save()
"""No-op: cache persistence removed."""
return
@staticmethod
def is_operational():

View File

@@ -1,5 +1,7 @@
from utils.doorman_cache_util import doorman_cache
from utils.database import routing_collection
from utils import api_util
from typing import Optional, Dict
import logging
@@ -32,4 +34,43 @@ async def get_routing_info(client_key):
server_index = (server_index + 1) % len(api_servers)
routing['server_index'] = server_index
doorman_cache.set_cache('client_routing_cache', client_key, routing)
return server
return server
async def pick_upstream_server(api: Dict, method: str, endpoint_uri: str, client_key: Optional[str]) -> Optional[str]:
"""Resolve upstream server with precedence: Routing (1) > Endpoint (2) > API (3).
- Routing: client-specific routing list with round-robin in the routing doc/cache.
- Endpoint: endpoint_servers list on the endpoint doc, round-robin via cache key endpoint_id.
- API: api_servers list on the API doc, round-robin via cache key api_id.
"""
# 1) Client routing
if client_key:
server = await get_routing_info(client_key)
if server:
return server
# 2) Endpoint-level servers
try:
endpoint = await api_util.get_endpoint(api, method, endpoint_uri)
except Exception:
endpoint = None
if endpoint:
ep_servers = endpoint.get('endpoint_servers') or []
if isinstance(ep_servers, list) and len(ep_servers) > 0:
idx_key = endpoint.get('endpoint_id') or f"{api.get('api_id')}:{method}:{endpoint_uri}"
server_index = doorman_cache.get_cache('endpoint_server_cache', idx_key) or 0
server = ep_servers[server_index % len(ep_servers)]
doorman_cache.set_cache('endpoint_server_cache', idx_key, (server_index + 1) % len(ep_servers))
return server
# 3) API-level servers
api_servers = api.get('api_servers') or []
if isinstance(api_servers, list) and len(api_servers) > 0:
idx_key = api.get('api_id')
server_index = doorman_cache.get_cache('endpoint_server_cache', idx_key) or 0
server = api_servers[server_index % len(api_servers)]
doorman_cache.set_cache('endpoint_server_cache', idx_key, (server_index + 1) % len(api_servers))
return server
return None

View File

@@ -0,0 +1,175 @@
'use client'
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
export default function AddEndpointPage() {
const params = useParams()
const router = useRouter()
const apiId = params.apiId as string
const [apiName, setApiName] = useState('')
const [apiVersion, setApiVersion] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [method, setMethod] = useState('GET')
const [uri, setUri] = useState('')
const [description, setDescription] = useState('')
const [useOverride, setUseOverride] = useState(false)
const [servers, setServers] = useState<string[]>([])
const [newServer, setNewServer] = useState('')
useEffect(() => {
try {
const apiData = sessionStorage.getItem('selectedApi')
if (apiData) {
const parsed = JSON.parse(apiData)
setApiName(parsed.api_name || '')
setApiVersion(parsed.api_version || '')
}
} catch {}
}, [])
const addServer = () => {
const v = newServer.trim()
if (!v) return
if (servers.includes(v)) return
setServers(prev => [...prev, v])
setNewServer('')
}
const removeServer = (idx: number) => {
setServers(prev => prev.filter((_, i) => i !== idx))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!uri.trim()) return
setLoading(true)
setError(null)
try {
const body: any = {
api_name: apiName,
api_version: apiVersion,
endpoint_method: method,
endpoint_uri: uri.startsWith('/') ? uri : '/' + uri,
endpoint_description: description || `${method} ${uri}`
}
if (useOverride && servers.length > 0) {
body.endpoint_servers = servers
}
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint`, {
method: 'POST',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
},
body: JSON.stringify(body)
})
const data = await response.json()
if (!response.ok) throw new Error(data.error_message || 'Failed to create endpoint')
setSuccess('Endpoint created')
setTimeout(() => setSuccess(null), 1500)
router.push(`/apis/${encodeURIComponent(apiId)}/endpoints`)
} catch (e:any) {
setError(e?.message || 'Failed to create endpoint')
} finally {
setLoading(false)
}
}
return (
<Layout>
<div className="space-y-6">
<div className="page-header">
<div>
<h1 className="page-title">Add Endpoint</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">For API {apiName}/{apiVersion}</p>
</div>
<div className="flex gap-2">
<Link href={`/apis/${encodeURIComponent(apiId)}/endpoints`} className="btn btn-secondary">Back to Endpoints</Link>
</div>
</div>
{success && (
<div className="rounded-lg bg-success-50 border border-success-200 p-4 dark:bg-success-900/20 dark:border-success-800">
<div className="flex"><p className="text-sm text-success-700 dark:text-success-300">{success}</p></div>
</div>
)}
{error && (
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
<div className="flex"><p className="text-sm text-error-700 dark:text-error-300">{error}</p></div>
</div>
)}
<div className="card max-w-3xl">
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Method</label>
<select className="input" value={method} onChange={e => setMethod(e.target.value)}>
{['GET','POST','PUT','DELETE','PATCH','HEAD','OPTIONS'].map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">URI</label>
<input className="input" value={uri} onChange={e => setUri(e.target.value)} placeholder="/path/{id}" />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1">Description</label>
<input className="input" value={description} onChange={e => setDescription(e.target.value)} placeholder="Describe endpoint" />
</div>
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-2">
<input id="use-override" type="checkbox" className="h-4 w-4" checked={useOverride} onChange={(e)=>setUseOverride(e.target.checked)} />
<label htmlFor="use-override" className="text-sm">Use endpoint servers (override API servers)</label>
</div>
<div className={`flex gap-2 ${useOverride ? '' : 'opacity-60'}`}>
<input
className="input flex-1"
value={newServer}
onChange={e => setNewServer(e.target.value)}
placeholder="e.g., http://localhost:8082"
onKeyPress={(e) => useOverride && e.key === 'Enter' && addServer()}
disabled={!useOverride}
/>
<button type="button" onClick={addServer} className="btn btn-secondary" disabled={!useOverride}>Add</button>
</div>
<div className={`mt-2 space-y-2 ${useOverride ? '' : 'opacity-60'}`}>
{servers.map((srv, idx) => (
<div key={idx} className="flex items-center justify-between bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded">
<span className="text-sm font-mono">{srv}</span>
<button type="button" onClick={() => removeServer(idx)} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" disabled={!useOverride}>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
{!useOverride && (
<p className="text-xs text-gray-500 dark:text-gray-400">Disabled API servers will be used unless enabled.</p>
)}
{useOverride && servers.length === 0 && (
<p className="text-xs text-gray-500 dark:text-gray-400">No endpoint-specific servers. API servers will be used.</p>
)}
</div>
</div>
<div className="md:col-span-2">
<button type="submit" className="btn btn-primary" disabled={loading || !uri.trim()}>
{loading ? <div className="flex items-center"><div className="spinner mr-2"></div>Creating...</div> : 'Create Endpoint'}
</button>
<Link href={`/apis/${encodeURIComponent(apiId)}/endpoints`} className="btn btn-ghost ml-2">Cancel</Link>
</div>
</form>
</div>
</div>
</Layout>
)
}

View File

@@ -0,0 +1,338 @@
'use client'
import React, { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
interface EndpointItem {
api_name: string
api_version: string
endpoint_method: string
endpoint_uri: string
endpoint_description?: string
endpoint_id?: string
endpoint_servers?: string[]
}
export default function ApiEndpointsPage() {
const params = useParams()
const router = useRouter()
const apiId = params.apiId as string
const [apiName, setApiName] = useState('')
const [apiVersion, setApiVersion] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [endpoints, setEndpoints] = useState<EndpointItem[]>([])
const [allEndpoints, setAllEndpoints] = useState<EndpointItem[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [sortBy, setSortBy] = useState<'method' | 'uri' | 'servers'>('method')
const [working, setWorking] = useState<Record<string, boolean>>({})
const [epNewServer, setEpNewServer] = useState<Record<string, string>>({})
useEffect(() => {
// Try to read API selection from session storage to display name/version for breadcrumbs
try {
const apiData = sessionStorage.getItem('selectedApi')
if (apiData) {
const parsed = JSON.parse(apiData)
setApiName(parsed.api_name || '')
setApiVersion(parsed.api_version || '')
}
} catch {}
}, [])
const loadEndpoints = async () => {
setLoading(true)
setError(null)
try {
if (!apiName || !apiVersion) {
// Fallback: fetch from server using apiId? Backend doesnt have by-id endpoint list; rely on session.
}
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(apiName)}/${encodeURIComponent(apiVersion)}` ,{
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
}
})
const data = await response.json()
if (!response.ok) throw new Error(data.error_message || 'Failed to load endpoints')
const list = data.endpoints || []
setEndpoints(list)
setAllEndpoints(list)
} catch (e:any) {
setError(e?.message || 'Failed to load endpoints')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (apiName && apiVersion) {
loadEndpoints()
} else {
setLoading(false)
}
}, [apiName, apiVersion])
const keyFor = (ep: EndpointItem) => `${ep.endpoint_method}:${ep.endpoint_uri}`
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set())
const filtered = useMemo(() => {
const t = searchTerm.trim().toLowerCase()
let list = allEndpoints
if (t) {
list = list.filter(ep =>
ep.endpoint_method.toLowerCase().includes(t) ||
ep.endpoint_uri.toLowerCase().includes(t) ||
(ep.endpoint_description || '').toLowerCase().includes(t)
)
}
const sorted = [...list].sort((a, b) => {
if (sortBy === 'method') return a.endpoint_method.localeCompare(b.endpoint_method)
if (sortBy === 'uri') return a.endpoint_uri.localeCompare(b.endpoint_uri)
const ac = (a.endpoint_servers || []).length
const bc = (b.endpoint_servers || []).length
return ac - bc
})
return sorted
}, [allEndpoints, searchTerm, sortBy])
const deleteEndpoint = async (ep: EndpointItem) => {
const k = keyFor(ep)
setWorking(prev => ({ ...prev, [k]: true }))
setError(null)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(ep.endpoint_method)}/${encodeURIComponent(ep.api_name)}/${encodeURIComponent(ep.api_version)}/${encodeURIComponent(ep.endpoint_uri.replace(/^\//, ''))}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
}
})
const data = await response.json()
if (!response.ok) throw new Error(data.error_message || 'Failed to delete endpoint')
await loadEndpoints()
setSuccess('Endpoint deleted')
setTimeout(() => setSuccess(null), 2000)
} catch (e:any) {
setError(e?.message || 'Failed to delete endpoint')
} finally {
setWorking(prev => ({ ...prev, [k]: false }))
}
}
const saveEndpointServers = async (ep: EndpointItem, servers: string[]) => {
const k = keyFor(ep)
setWorking(prev => ({ ...prev, [k]: true }))
setError(null)
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(ep.endpoint_method)}/${encodeURIComponent(ep.api_name)}/${encodeURIComponent(ep.api_version)}/${encodeURIComponent(ep.endpoint_uri.replace(/^\//, ''))}`, {
method: 'PUT',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
},
body: JSON.stringify({ endpoint_servers: servers })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error_message || 'Failed to update endpoint')
await loadEndpoints()
setSuccess('Endpoint servers updated')
setTimeout(() => setSuccess(null), 2000)
} catch (e:any) {
setError(e?.message || 'Failed to update endpoint')
} finally {
setWorking(prev => ({ ...prev, [k]: false }))
}
}
const addEndpointServer = async (ep: EndpointItem) => {
const k = keyFor(ep)
const value = (epNewServer[k] || '').trim()
if (!value) return
const next = [...(ep.endpoint_servers || [])]
if (!next.includes(value)) next.push(value)
await saveEndpointServers(ep, next)
setEpNewServer(prev => ({ ...prev, [k]: '' }))
}
const removeEndpointServer = async (ep: EndpointItem, index: number) => {
const next = (ep.endpoint_servers || []).filter((_, i) => i !== index)
await saveEndpointServers(ep, next)
}
return (
<Layout>
<div className="space-y-6">
<div className="page-header">
<div>
<h1 className="page-title">Endpoints for {apiName}/{apiVersion}</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Create, edit, and delete endpoints. Precedence: Routing (client-key) Endpoint servers API servers.</p>
</div>
<div className="flex gap-2">
<Link href={`/apis/${encodeURIComponent(apiId)}/endpoints/add`} className="btn btn-primary">
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Endpoint
</Link>
<Link href={`/apis/${encodeURIComponent(apiId)}`} className="btn btn-ghost">Back</Link>
</div>
</div>
{success && (
<div className="rounded-lg bg-success-50 border border-success-200 p-4 dark:bg-success-900/20 dark:border-success-800">
<div className="flex"><p className="text-sm text-success-700 dark:text-success-300">{success}</p></div>
</div>
)}
{error && (
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
<div className="flex"><p className="text-sm text-error-700 dark:text-error-300">{error}</p></div>
</div>
)}
{/* Search and Filters */}
<div className="card">
<div className="flex flex-col sm:flex-row gap-4">
<form onSubmit={(e) => { e.preventDefault(); }} className="flex-1">
<div className="relative">
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
className="search-input"
placeholder="Search endpoints by method, URI, or description..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</form>
<div className="flex gap-2">
<button onClick={() => setSortBy('method')} className={`btn ${sortBy === 'method' ? 'btn-primary' : 'btn-secondary'}`}>Method</button>
<button onClick={() => setSortBy('uri')} className={`btn ${sortBy === 'uri' ? 'btn-primary' : 'btn-secondary'}`}>URI</button>
<button onClick={() => setSortBy('servers')} className={`btn ${sortBy === 'servers' ? 'btn-primary' : 'btn-secondary'}`}>Servers</button>
</div>
</div>
</div>
<div className="card">
<div className="overflow-x-auto">
<table className="table">
<thead>
<tr>
<th></th>
<th>Method</th>
<th>URI</th>
<th>Description</th>
<th>Routing</th>
<th>Servers</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="text-center py-8 text-gray-500">Loading endpoints...</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={6} className="text-center py-8 text-gray-500">No endpoints found.</td></tr>
) : (
filtered.map((ep) => {
const k = keyFor(ep)
const saving = !!working[k]
const hasOverride = (ep.endpoint_servers || []).length > 0
const [expanded, setExpanded] = [undefined as any, undefined as any]
return (
<React.Fragment key={k}>
<tr className="cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-surfaceHover" onClick={() => setExpandedKeys(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n })}>
<td>
<button className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" onClick={(e) => { e.stopPropagation(); setExpandedKeys(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n }) }}>
<svg className={`h-4 w-4 transform transition-transform ${expandedKeys.has(k) ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</td>
<td>
<span className={`badge ${ep.endpoint_method === 'GET' ? 'badge-success' : ep.endpoint_method === 'POST' ? 'badge-primary' : 'badge-warning'}`}>{ep.endpoint_method}</span>
</td>
<td className="font-mono text-sm">{ep.endpoint_uri}</td>
<td className="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">{ep.endpoint_description || '-'}</td>
<td>
<span className={`badge ${hasOverride ? 'badge-primary' : 'badge-gray'}`} title="Routing precedence: client-key → endpoint → API">
{hasOverride ? 'Endpoint override' : 'API default'}
</span>
</td>
<td>
<span className="badge badge-secondary">{(ep.endpoint_servers || []).length}</span>
</td>
</tr>
{expandedKeys.has(k) && (
<tr>
<td colSpan={6} className="p-0">
<div className="bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div className="p-4 space-y-3">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={hasOverride}
onChange={async (e) => {
const on = e.target.checked
if (!on) {
await saveEndpointServers(ep, [])
}
}}
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Use endpoint servers</span>
</div>
<div className="flex-1" />
<button onClick={(e) => { e.stopPropagation(); deleteEndpoint(ep) }} className="btn btn-error btn-sm">Delete Endpoint</button>
</div>
<div className={`${hasOverride ? '' : 'opacity-60'}`}>
<div className="text-sm font-medium mb-1">Endpoint Servers (override API servers)</div>
<div className="space-y-2">
{(ep.endpoint_servers || []).map((srv, idx) => (
<div key={idx} className="flex items-center justify-between bg-white dark:bg-gray-900 px-3 py-2 rounded border">
<span className="text-sm font-mono">{srv}</span>
<button disabled={saving || !hasOverride} onClick={() => removeEndpointServer(ep, idx)} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
{(ep.endpoint_servers || []).length === 0 && (
<p className="text-xs text-gray-500">No endpoint-specific servers. Using API servers.</p>
)}
</div>
<div className="mt-2 flex gap-2">
<input className="input flex-1" value={epNewServer[k] || ''} onChange={e => setEpNewServer(prev => ({ ...prev, [k]: e.target.value }))} placeholder="Add server URL" onKeyPress={(e) => e.key === 'Enter' && hasOverride && addEndpointServer(ep)} disabled={!hasOverride} />
<button disabled={saving || !hasOverride} onClick={() => addEndpointServer(ep)} className="btn btn-secondary">{saving ? <div className="flex items-center"><div className="spinner mr-2"></div>Saving...</div> : 'Add'}</button>
</div>
</div>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
</Layout>
)
}

View File

@@ -22,6 +22,16 @@ interface API {
api_path?: string
}
interface EndpointItem {
api_name: string
api_version: string
endpoint_method: string
endpoint_uri: string
endpoint_description?: string
endpoint_id?: string
endpoint_servers?: string[]
}
interface UpdateApiData {
api_name?: string
api_version?: string
@@ -52,6 +62,9 @@ const ApiDetailPage = () => {
const [newGroup, setNewGroup] = useState('')
const [newServer, setNewServer] = useState('')
const [newHeader, setNewHeader] = useState('')
const [endpoints, setEndpoints] = useState<EndpointItem[]>([])
const [epNewServer, setEpNewServer] = useState<Record<string, string>>({})
const [epSaving, setEpSaving] = useState<Record<string, boolean>>({})
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deleteConfirmation, setDeleteConfirmation] = useState('')
const [deleting, setDeleting] = useState(false)
@@ -87,6 +100,29 @@ const ApiDetailPage = () => {
}
}, [apiId])
useEffect(() => {
const loadEndpoints = async () => {
if (!api) return
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(api.api_name)}/${encodeURIComponent(api.api_version)}` ,{
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
}
})
const data = await response.json()
if (!response.ok) throw new Error(data.error_message || 'Failed to load endpoints')
setEndpoints(data.endpoints || [])
} catch (e) {
// endpoints optional; do not hard fail page
console.warn('Failed to load endpoints for API', e)
}
}
loadEndpoints()
}, [api])
const handleBack = () => {
router.push('/apis')
}
@@ -222,6 +258,60 @@ const ApiDetailPage = () => {
}))
}
const addEndpointServer = async (ep: EndpointItem) => {
const key = `${ep.endpoint_method}:${ep.endpoint_uri}`
const value = (epNewServer[key] || '').trim()
if (!value) return
const next = [...(ep.endpoint_servers || [])]
if (next.includes(value)) return
next.push(value)
await saveEndpointServers(ep, next)
setEpNewServer(prev => ({ ...prev, [key]: '' }))
}
const removeEndpointServer = async (ep: EndpointItem, index: number) => {
const next = (ep.endpoint_servers || []).filter((_, i) => i !== index)
await saveEndpointServers(ep, next)
}
const saveEndpointServers = async (ep: EndpointItem, servers: string[]) => {
if (!api) return
const key = `${ep.endpoint_method}:${ep.endpoint_uri}`
setEpSaving(prev => ({ ...prev, [key]: true }))
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(ep.endpoint_method)}/${encodeURIComponent(ep.api_name)}/${encodeURIComponent(ep.api_version)}/${encodeURIComponent(ep.endpoint_uri.replace(/^\//, ''))}`, {
method: 'PUT',
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
},
body: JSON.stringify({ endpoint_servers: servers })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error_message || 'Failed to save endpoint servers')
// refresh endpoints
const refreshed = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(api.api_name)}/${encodeURIComponent(api.api_version)}` ,{
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
}
})
const refreshedData = await refreshed.json()
setEndpoints(refreshedData.endpoints || [])
setSuccess('Endpoint servers updated')
setTimeout(() => setSuccess(null), 2000)
} catch (e:any) {
setError(e?.message || 'Failed to update endpoint')
setTimeout(() => setError(null), 3000)
} finally {
setEpSaving(prev => ({ ...prev, [key]: false }))
}
}
const addHeader = () => {
if (newHeader.trim() && !editData.api_allowed_headers?.includes(newHeader.trim())) {
setEditData(prev => ({
@@ -349,6 +439,12 @@ const ApiDetailPage = () => {
</svg>
Edit API
</button>
<Link href={`/apis/${encodeURIComponent(apiId)}/endpoints`} className="btn btn-secondary">
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
Manage Endpoints
</Link>
<button onClick={handleDeleteClick} className="btn btn-error">
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
@@ -676,6 +772,7 @@ const ApiDetailPage = () => {
<div className="card">
<div className="card-header">
<h3 className="card-title">Servers</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">Used when no client routing or endpoint override is configured</p>
</div>
<div className="p-6 space-y-4">
{isEditing && (
@@ -718,6 +815,78 @@ const ApiDetailPage = () => {
</div>
</div>
{/* Endpoint Overrides */}
<div className="card lg:col-span-2">
<div className="card-header">
<h3 className="card-title">Endpoint Overrides</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">Precedence: Routing (client-key) Endpoint servers API servers</p>
</div>
<div className="p-6 space-y-4">
{endpoints.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-sm">No endpoints found for this API.</p>
) : (
endpoints.map((ep) => {
const key = `${ep.endpoint_method}:${ep.endpoint_uri}`
const saving = !!epSaving[key]
const enabled = (ep.endpoint_servers || []).length > 0
return (
<div key={key} className="border rounded-lg p-4 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-500 dark:text-gray-400">{ep.endpoint_method}</div>
<div className="font-mono text-gray-900 dark:text-gray-100">{ep.endpoint_uri}</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={enabled}
onChange={async (e) => {
const on = e.target.checked
if (!on) {
await removeEndpointServer(ep, -1) // noop fallback
await saveEndpointServers(ep, [])
}
}}
/>
<span className="text-sm text-gray-600 dark:text-gray-300">Use endpoint servers</span>
</div>
</div>
<div className={`mt-3 space-y-2 ${enabled ? '' : 'opacity-60'}`}>
{(ep.endpoint_servers || []).map((srv, idx) => (
<div key={idx} className="flex items-center justify-between bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded">
<span className="text-sm font-mono">{srv}</span>
<button disabled={saving || !enabled} onClick={() => removeEndpointServer(ep, idx)} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
{(ep.endpoint_servers || []).length === 0 && (
<p className="text-gray-500 dark:text-gray-400 text-sm">No endpoint-specific servers. Using API servers.</p>
)}
</div>
<div className="mt-3 flex gap-2">
<input
type="text"
value={epNewServer[key] || ''}
onChange={(e) => setEpNewServer(prev => ({ ...prev, [key]: e.target.value }))}
className={`input flex-1 ${enabled ? '' : 'opacity-60'}`}
placeholder="Add endpoint server URL"
onKeyPress={(e) => e.key === 'Enter' && enabled && addEndpointServer(ep)}
disabled={!enabled}
/>
<button disabled={saving || !enabled} onClick={() => addEndpointServer(ep)} className="btn btn-primary">
{saving ? <div className="flex items-center"><div className="spinner mr-2"></div>Saving...</div> : 'Add'}
</button>
</div>
</div>
)
})
)}
</div>
</div>
{/* Allowed Headers */}
<div className="card">
<div className="card-header">

View File

@@ -13,10 +13,11 @@ const AddApiPage = () => {
api_name: '',
api_version: '',
api_type: 'REST',
api_path: '',
api_servers: [] as string[],
api_description: '',
validation_enabled: false
})
const [newServer, setNewServer] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -56,15 +57,27 @@ const AddApiPage = () => {
}))
}
const addServer = () => {
const value = newServer.trim()
if (!value) return
if (formData.api_servers.includes(value)) return
setFormData(prev => ({ ...prev, api_servers: [...prev.api_servers, value] }))
setNewServer('')
}
const removeServer = (index: number) => {
setFormData(prev => ({ ...prev, api_servers: prev.api_servers.filter((_, i) => i !== index) }))
}
return (
<Layout>
<div className="space-y-6">
{/* Page Header */}
<div className="page-header">
<div>
<h1 className="page-title">Add New API</h1>
<h1 className="page-title">Add API</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Create a new API endpoint for your gateway
Define a new API and its default upstream servers
</p>
</div>
<Link href="/apis" className="btn btn-secondary">
@@ -158,23 +171,37 @@ const AddApiPage = () => {
</div>
<div>
<label htmlFor="api_path" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Path *
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Servers
</label>
<input
id="api_path"
name="api_path"
type="text"
required
className="input"
placeholder="e.g., https://api.example.com"
value={formData.api_path}
onChange={handleChange}
disabled={loading}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
The base URL or path for your API
</p>
<div className="flex gap-2">
<input
type="text"
className="input flex-1"
placeholder="e.g., http://localhost:8080"
value={newServer}
onChange={(e) => setNewServer(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addServer()}
disabled={loading}
/>
<button type="button" onClick={addServer} className="btn btn-secondary" disabled={loading}>Add</button>
</div>
<div className="mt-2 space-y-2">
{formData.api_servers.map((srv, idx) => (
<div key={idx} className="flex items-center justify-between bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded">
<span className="text-sm font-mono text-gray-800 dark:text-gray-200">{srv}</span>
<button type="button" onClick={() => removeServer(idx)} className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
{formData.api_servers.length === 0 && (
<p className="text-xs text-gray-500 dark:text-gray-400">No servers added yet</p>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">These are the default upstreams for this API. You can override per-endpoint later.</p>
</div>
<div>
@@ -237,4 +264,4 @@ const AddApiPage = () => {
)
}
export default AddApiPage
export default AddApiPage

View File

@@ -9,7 +9,7 @@ interface API {
api_version: React.ReactNode
api_type: React.ReactNode
api_description: React.ReactNode
api_path: React.ReactNode
api_servers?: string[]
api_id: React.ReactNode
api_name: React.ReactNode
id: string
@@ -38,7 +38,7 @@ const APIsPage = () => {
try {
setLoading(true)
setError(null)
const response = await fetch(`http://localhost:3002/platform/api/all`, {
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/api/all`, {
credentials: 'include',
headers: {
'Accept': 'application/json',
@@ -73,7 +73,7 @@ const APIsPage = () => {
(api.api_name as string)?.toLowerCase().includes(searchTerm.toLowerCase()) ||
(api.api_version as string)?.toLowerCase().includes(searchTerm.toLowerCase()) ||
(api.api_type as string)?.toLowerCase().includes(searchTerm.toLowerCase()) ||
(api.api_path as string)?.toLowerCase().includes(searchTerm.toLowerCase()) ||
((api.api_servers || []).join(',').toLowerCase().includes(searchTerm.toLowerCase())) ||
(api.api_description as string)?.toLowerCase().includes(searchTerm.toLowerCase())
)
setApis(filteredApis)
@@ -99,6 +99,12 @@ const APIsPage = () => {
router.push(`/apis/${api.api_id}`)
}
const handleViewEndpoints = (e: React.MouseEvent, api: API) => {
e.stopPropagation()
sessionStorage.setItem('selectedApi', JSON.stringify(api))
router.push(`/apis/${api.api_id}/endpoints`)
}
return (
<Layout>
<div className="space-y-6">
@@ -192,10 +198,10 @@ const APIsPage = () => {
<tr>
<th>Name</th>
<th>Version</th>
<th>Path</th>
<th>Servers</th>
<th>Description</th>
<th>Type</th>
<th></th>
<th className="w-56">Actions</th>
</tr>
</thead>
<tbody>
@@ -222,9 +228,14 @@ const APIsPage = () => {
<span className="badge badge-primary">{api.api_version}</span>
</td>
<td>
<code className="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">
{api.api_path}
</code>
{Array.isArray((api as any).api_servers) && (api as any).api_servers.length > 0 ? (
<div className="text-sm text-gray-700 dark:text-gray-300 max-w-xs truncate">
{(api as any).api_servers.slice(0, 3).join(', ')}
{(api as any).api_servers.length > 3 && ' …'}
</div>
) : (
<span className="text-gray-500 dark:text-gray-400 text-sm">None</span>
)}
</td>
<td>
<p className="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate">
@@ -241,12 +252,28 @@ const APIsPage = () => {
{api.api_type}
</span>
</td>
<td>
<button className="btn btn-ghost btn-sm">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<td className="whitespace-nowrap">
<div className="flex items-center gap-2">
<button
className="btn btn-secondary btn-sm"
onClick={(e) => handleViewEndpoints(e, api)}
title="View endpoints for this API"
>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
View Endpoints
</button>
<button
className="btn btn-ghost btn-sm"
onClick={(e) => { e.stopPropagation(); handleApiClick(api); }}
title="Open API details"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</td>
</tr>
))}

View File

@@ -49,6 +49,8 @@ interface GroupedLogs {
expanded_logs?: Log[] // Store all logs for this request when expanded
}
type OverrideKey = string // `${method}|${api_name}|${api_version}|${endpoint_uri}`
export default function LogsPage() {
const [logs, setLogs] = useState<Log[]>([])
const [groupedLogs, setGroupedLogs] = useState<GroupedLogs[]>([])
@@ -80,6 +82,36 @@ export default function LogsPage() {
}
})
const [overrideMap, setOverrideMap] = useState<Record<OverrideKey, boolean>>({})
const ensureEndpointOverridesLoaded = async (apiPath: string) => {
try {
const parts = apiPath.replace(/^\//, '').split('/')
if (parts.length < 2) return
const api_name = parts[0]
const api_version = parts[1]
const keyPrefix = `${api_name}|${api_version}|`
if (Object.keys(overrideMap).some(k => k.includes(keyPrefix))) return
const response = await fetch(`${process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3002'}/platform/endpoint/${encodeURIComponent(api_name)}/${encodeURIComponent(api_version)}`, {
credentials: 'include',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Cookie': `access_token_cookie=${document.cookie.split('; ').find(row => row.startsWith('access_token_cookie='))?.split('=')[1]}`
}
})
const data = await response.json()
if (!response.ok) return
const eps: any[] = data.endpoints || []
const next: Record<OverrideKey, boolean> = {}
eps.forEach(ep => {
const k: OverrideKey = `${ep.endpoint_method}|${ep.api_name}|${ep.api_version}|${ep.endpoint_uri}`
next[k] = Array.isArray(ep.endpoint_servers) && ep.endpoint_servers.length > 0
})
setOverrideMap(prev => ({ ...prev, ...next }))
} catch {}
}
const fetchLogs = useCallback(async () => {
try {
setLoading(true)
@@ -259,6 +291,11 @@ export default function LogsPage() {
const responseTimeLog = sortedLogs.find(log => log.response_time)
const userLog = sortedLogs.find(log => log.user)
const endpointLog = sortedLogs.find(log => log.endpoint && log.method)
const apiHintLog = sortedLogs.find(log => log.api)?.api
if (apiHintLog) {
// Best effort: load endpoint override info for this API path
ensureEndpointOverridesLoaded(apiHintLog as string)
}
const hasError = sortedLogs.some(log => log.level.toLowerCase() === 'error')
return {
@@ -658,6 +695,7 @@ export default function LogsPage() {
<th>Duration</th>
<th>User</th>
<th>Endpoint</th>
<th>Routing</th>
<th>Method</th>
<th>Response Time</th>
<th>Status</th>
@@ -707,6 +745,26 @@ export default function LogsPage() {
{group.endpoint || '-'}
</p>
</td>
<td>
{(() => {
if (!group.endpoint || !group.method) return '-'
const m = (group.endpoint || '').match(/^\/?([^/]+\/v\d+)(?:\/(.*))?$/)
if (!m) return '-'
const apiPath = m[1]
const epUri = '/' + (m[2] || '')
const parts = apiPath.split('/')
if (parts.length < 2) return '-'
const api_name = parts[0]
const api_version = parts[1]
const k: OverrideKey = `${group.method}|${api_name}|${api_version}|${epUri}`
const hasOverride = !!overrideMap[k]
return (
<span className={`badge ${hasOverride ? 'badge-primary' : 'badge-gray'}`} title="Routing precedence: client-key → endpoint → API">
{hasOverride ? 'Endpoint override' : 'API default'}
</span>
)
})()}
</td>
<td>
<span className={`badge ${group.method === 'GET' ? 'badge-success' : group.method === 'POST' ? 'badge-primary' : 'badge-warning'}`}>
{group.method || '-'}
@@ -727,7 +785,7 @@ export default function LogsPage() {
{/* Expanded logs for this request */}
{expandedRequests.has(group.request_id) && (
<tr>
<td colSpan={9} className="p-0">
<td colSpan={10} className="p-0">
<div className="bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<div className="p-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
@@ -804,4 +862,4 @@ export default function LogsPage() {
</div>
</Layout>
)
}
}

View File

@@ -147,26 +147,6 @@ const AddRoutingPage = () => {
</p>
</div>
<div>
<label htmlFor="server_index" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Default Server Index
</label>
<input
type="number"
id="server_index"
name="server_index"
className="input"
placeholder="0"
value={formData.server_index || 0}
onChange={(e) => handleInputChange('server_index', parseInt(e.target.value))}
disabled={loading}
min="0"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Index of the default server in the list (0-based)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Servers *