mirror of
https://github.com/apidoorman/doorman.git
synced 2026-02-20 00:28:30 -06:00
Update to memory saves. Added endpoint mangement per API
This commit is contained in:
10
README.md
10
README.md
@@ -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!
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
175
web-client/src/app/apis/[apiId]/endpoints/add/page.tsx
Normal file
175
web-client/src/app/apis/[apiId]/endpoints/add/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
338
web-client/src/app/apis/[apiId]/endpoints/page.tsx
Normal file
338
web-client/src/app/apis/[apiId]/endpoints/page.tsx
Normal 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 doesn’t 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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 *
|
||||
|
||||
Reference in New Issue
Block a user