tool tips and credit page updates

This commit is contained in:
seniorswe
2025-09-26 16:49:47 -04:00
parent baef7f302a
commit 393ba71272
24 changed files with 1008 additions and 116 deletions

View File

@@ -40,6 +40,7 @@ from routes.security_routes import security_router
from routes.credit_routes import credit_router
from routes.demo_routes import demo_router
from routes.monitor_routes import monitor_router
from routes.config_routes import config_router
from utils.security_settings_util import load_settings, start_auto_save_task, stop_auto_save_task, get_cached_settings
from utils.memory_dump_util import dump_memory_to_file, restore_memory_from_file, find_latest_dump_path
from utils.metrics_util import metrics_store
@@ -452,6 +453,7 @@ doorman.include_router(monitor_router, prefix="/platform", tags=["Monitor"])
# Expose token management under both legacy and new prefixes
doorman.include_router(credit_router, prefix="/platform/credit", tags=["Credit"])
doorman.include_router(demo_router, prefix="/platform/demo", tags=["Demo"])
doorman.include_router(config_router, prefix="/platform", tags=["Config"])
def start():
if os.path.exists(PID_FILE):

View File

@@ -0,0 +1,304 @@
"""
Routes to export and import platform configuration (APIs, Endpoints, Roles, Groups, Routings).
"""
from fastapi import APIRouter, Request
from typing import Any, Dict, List, Optional
import uuid
import time
import logging
import copy
from models.response_model import ResponseModel
from utils.response_util import process_response
from utils.auth_util import auth_required
from utils.role_util import platform_role_required_bool
from utils.doorman_cache_util import doorman_cache
from utils.database import (
api_collection,
endpoint_collection,
group_collection,
role_collection,
routing_collection,
)
config_router = APIRouter()
logger = logging.getLogger("doorman.gateway")
def _strip_id(doc: Dict[str, Any]) -> Dict[str, Any]:
d = dict(doc)
d.pop("_id", None)
return d
def _export_all() -> Dict[str, Any]:
apis = [_strip_id(a) for a in api_collection.find().to_list(length=None)]
endpoints = [_strip_id(e) for e in endpoint_collection.find().to_list(length=None)]
roles = [_strip_id(r) for r in role_collection.find().to_list(length=None)]
groups = [_strip_id(g) for g in group_collection.find().to_list(length=None)]
routings = [_strip_id(r) for r in routing_collection.find().to_list(length=None)]
return {
"apis": apis,
"endpoints": endpoints,
"roles": roles,
"groups": groups,
"routings": routings,
}
@config_router.get("/config/export/all",
description="Export all platform configuration (APIs, Endpoints, Roles, Groups, Routings)",
response_model=ResponseModel,
)
async def export_all(request: Request):
request_id = str(uuid.uuid4())
start = time.time() * 1000
try:
payload = await auth_required(request)
username = payload.get("sub")
if not await platform_role_required_bool(username, 'manage_gateway'):
return process_response(ResponseModel(status_code=403, error_code="CFG001", error_message="Insufficient permissions").dict(), "rest")
data = _export_all()
return process_response(ResponseModel(status_code=200, response_headers={"request_id": request_id}, response=data).dict(), "rest")
except Exception as e:
logger.error(f"{request_id} | export_all error: {e}")
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
finally:
logger.info(f"{request_id} | export_all took {time.time()*1000 - start:.2f}ms")
@config_router.get("/config/export/apis",
description="Export APIs (optionally a single API with its endpoints)",
response_model=ResponseModel)
async def export_apis(request: Request, api_name: Optional[str] = None, api_version: Optional[str] = None):
request_id = str(uuid.uuid4())
start = time.time() * 1000
try:
payload = await auth_required(request)
username = payload.get("sub")
if not await platform_role_required_bool(username, 'manage_apis'):
return process_response(ResponseModel(status_code=403, error_code="CFG002", error_message="Insufficient permissions").dict(), "rest")
if api_name and api_version:
api = api_collection.find_one({"api_name": api_name, "api_version": api_version})
if not api:
return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="API not found").dict(), "rest")
aid = api.get('api_id')
eps = endpoint_collection.find({"api_name": api_name, "api_version": api_version}).to_list(length=None)
return process_response(ResponseModel(status_code=200, response={
"api": _strip_id(api),
"endpoints": [_strip_id(e) for e in eps]
}).dict(), "rest")
apis = [_strip_id(a) for a in api_collection.find().to_list(length=None)]
return process_response(ResponseModel(status_code=200, response={"apis": apis}).dict(), "rest")
except Exception as e:
logger.error(f"{request_id} | export_apis error: {e}")
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
finally:
logger.info(f"{request_id} | export_apis took {time.time()*1000 - start:.2f}ms")
@config_router.get("/config/export/roles", description="Export Roles", response_model=ResponseModel)
async def export_roles(request: Request, role_name: Optional[str] = None):
request_id = str(uuid.uuid4())
try:
payload = await auth_required(request)
username = payload.get("sub")
if not await platform_role_required_bool(username, 'manage_roles'):
return process_response(ResponseModel(status_code=403, error_code="CFG003", error_message="Insufficient permissions").dict(), "rest")
if role_name:
role = role_collection.find_one({"role_name": role_name})
if not role:
return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="Role not found").dict(), "rest")
return process_response(ResponseModel(status_code=200, response={"role": _strip_id(role)}).dict(), "rest")
roles = [_strip_id(r) for r in role_collection.find().to_list(length=None)]
return process_response(ResponseModel(status_code=200, response={"roles": roles}).dict(), "rest")
except Exception as e:
logger.error(f"{request_id} | export_roles error: {e}")
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
@config_router.get("/config/export/groups", description="Export Groups", response_model=ResponseModel)
async def export_groups(request: Request, group_name: Optional[str] = None):
request_id = str(uuid.uuid4())
try:
payload = await auth_required(request)
username = payload.get("sub")
if not await platform_role_required_bool(username, 'manage_groups'):
return process_response(ResponseModel(status_code=403, error_code="CFG004", error_message="Insufficient permissions").dict(), "rest")
if group_name:
group = group_collection.find_one({"group_name": group_name})
if not group:
return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="Group not found").dict(), "rest")
return process_response(ResponseModel(status_code=200, response={"group": _strip_id(group)}).dict(), "rest")
groups = [_strip_id(g) for g in group_collection.find().to_list(length=None)]
return process_response(ResponseModel(status_code=200, response={"groups": groups}).dict(), "rest")
except Exception as e:
logger.error(f"{request_id} | export_groups error: {e}")
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
@config_router.get("/config/export/routings", description="Export Routings", response_model=ResponseModel)
async def export_routings(request: Request, client_key: Optional[str] = None):
request_id = str(uuid.uuid4())
try:
payload = await auth_required(request)
username = payload.get("sub")
if not await platform_role_required_bool(username, 'manage_routings'):
return process_response(ResponseModel(status_code=403, error_code="CFG005", error_message="Insufficient permissions").dict(), "rest")
if client_key:
routing = routing_collection.find_one({"client_key": client_key})
if not routing:
return process_response(ResponseModel(status_code=404, error_code="CFG404", error_message="Routing not found").dict(), "rest")
return process_response(ResponseModel(status_code=200, response={"routing": _strip_id(routing)}).dict(), "rest")
routings = [_strip_id(r) for r in routing_collection.find().to_list(length=None)]
return process_response(ResponseModel(status_code=200, response={"routings": routings}).dict(), "rest")
except Exception as e:
logger.error(f"{request_id} | export_routings error: {e}")
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
@config_router.get("/config/export/endpoints",
description="Export endpoints (optionally filter by api_name/api_version)",
response_model=ResponseModel)
async def export_endpoints(request: Request, api_name: Optional[str] = None, api_version: Optional[str] = None):
request_id = str(uuid.uuid4())
try:
payload = await auth_required(request)
username = payload.get("sub")
# Reuse manage_endpoints permission for endpoint export
if not await platform_role_required_bool(username, 'manage_endpoints'):
return process_response(ResponseModel(status_code=403, error_code="CFG007", error_message="Insufficient permissions").dict(), "rest")
query = {}
if api_name:
query['api_name'] = api_name
if api_version:
query['api_version'] = api_version
eps = [_strip_id(e) for e in endpoint_collection.find(query).to_list(length=None)]
return process_response(ResponseModel(status_code=200, response={"endpoints": eps}).dict(), "rest")
except Exception as e:
logger.error(f"{request_id} | export_endpoints error: {e}")
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
def _upsert_api(doc: Dict[str, Any]) -> None:
api_name = doc.get('api_name')
api_version = doc.get('api_version')
if not api_name or not api_version:
return
existing = api_collection.find_one({"api_name": api_name, "api_version": api_version})
to_set = copy.deepcopy(_strip_id(doc))
# Ensure identifiers
if existing:
if not to_set.get('api_id'):
to_set['api_id'] = existing.get('api_id')
else:
to_set.setdefault('api_id', str(uuid.uuid4()))
to_set.setdefault('api_path', f"/{api_name}/{api_version}")
if existing:
api_collection.update_one({"api_name": api_name, "api_version": api_version}, {"$set": to_set})
else:
api_collection.insert_one(to_set)
def _upsert_endpoint(doc: Dict[str, Any]) -> None:
api_name = doc.get('api_name')
api_version = doc.get('api_version')
method = doc.get('endpoint_method')
uri = doc.get('endpoint_uri')
if not (api_name and api_version and method and uri):
return
# Ensure endpoint_id and api_id
api_doc = api_collection.find_one({"api_name": api_name, "api_version": api_version})
to_set = copy.deepcopy(_strip_id(doc))
if api_doc:
to_set['api_id'] = api_doc.get('api_id')
to_set.setdefault('endpoint_id', str(uuid.uuid4()))
existing = endpoint_collection.find_one({
'api_name': api_name,
'api_version': api_version,
'endpoint_method': method,
'endpoint_uri': uri,
})
if existing:
endpoint_collection.update_one({
'api_name': api_name,
'api_version': api_version,
'endpoint_method': method,
'endpoint_uri': uri,
}, {"$set": to_set})
else:
endpoint_collection.insert_one(to_set)
def _upsert_role(doc: Dict[str, Any]) -> None:
name = doc.get('role_name')
if not name:
return
to_set = copy.deepcopy(_strip_id(doc))
existing = role_collection.find_one({'role_name': name})
if existing:
role_collection.update_one({'role_name': name}, {"$set": to_set})
else:
role_collection.insert_one(to_set)
def _upsert_group(doc: Dict[str, Any]) -> None:
name = doc.get('group_name')
if not name:
return
to_set = copy.deepcopy(_strip_id(doc))
existing = group_collection.find_one({'group_name': name})
if existing:
group_collection.update_one({'group_name': name}, {"$set": to_set})
else:
group_collection.insert_one(to_set)
def _upsert_routing(doc: Dict[str, Any]) -> None:
key = doc.get('client_key')
if not key:
return
to_set = copy.deepcopy(_strip_id(doc))
existing = routing_collection.find_one({'client_key': key})
if existing:
routing_collection.update_one({'client_key': key}, {"$set": to_set})
else:
routing_collection.insert_one(to_set)
@config_router.post("/config/import",
description="Import platform configuration (any subset of apis, endpoints, roles, groups, routings)",
response_model=ResponseModel)
async def import_all(request: Request, body: Dict[str, Any]):
request_id = str(uuid.uuid4())
start = time.time() * 1000
try:
payload = await auth_required(request)
username = payload.get("sub")
# Require broad gateway permission for bulk import
if not await platform_role_required_bool(username, 'manage_gateway'):
return process_response(ResponseModel(status_code=403, error_code="CFG006", error_message="Insufficient permissions").dict(), "rest")
counts = {"apis": 0, "endpoints": 0, "roles": 0, "groups": 0, "routings": 0}
for api in body.get('apis', []) or []:
_upsert_api(api); counts['apis'] += 1
for ep in body.get('endpoints', []) or []:
_upsert_endpoint(ep); counts['endpoints'] += 1
for r in body.get('roles', []) or []:
_upsert_role(r); counts['roles'] += 1
for g in body.get('groups', []) or []:
_upsert_group(g); counts['groups'] += 1
for rt in body.get('routings', []) or []:
_upsert_routing(rt); counts['routings'] += 1
# Invalidate caches so changes take immediate effect
try:
doorman_cache.clear_all_caches()
except Exception:
pass
return process_response(ResponseModel(status_code=200, response={"imported": counts}).dict(), "rest")
except Exception as e:
logger.error(f"{request_id} | import_all error: {e}")
return process_response(ResponseModel(status_code=500, error_code="GTW999", error_message="An unexpected error occurred").dict(), "rest")
finally:
logger.info(f"{request_id} | import_all took {time.time()*1000 - start:.2f}ms")

View File

@@ -139,7 +139,7 @@ async def restart_gateway(request: Request):
status_code=409,
response_headers={"request_id": request_id},
error_code="SEC004",
error_message="Restart not supported: no PID file found (run using 'doorman start' or use your orchestrator to restart)"
error_message="Restart not supported: no PID file found (run using 'doorman start' or contact your admin)"
).dict(), "rest")
# Spawn a detached helper to perform restart so this request can return 202
doorman_path = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'doorman.py'))

View File

@@ -0,0 +1,108 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Doorman Field Guide</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,"Noto Sans",sans-serif;line-height:1.5;margin:0;padding:0;color:#1f2937;background:#fff}
header{background:#111827;color:#e5e7eb;padding:16px 24px}
main{max-width:920px;margin:24px auto;padding:0 16px}
h1{font-size:24px;margin:0}
h2{margin-top:28px;border-bottom:1px solid #e5e7eb;padding-bottom:6px}
h3{margin-top:20px}
code{background:#f3f4f6;padding:2px 4px;border-radius:4px}
pre{background:#0b1020;color:#e5e7eb;padding:12px;border-radius:8px;overflow:auto}
.tip{background:#ecfeff;border:1px solid #a5f3fc;color:#155e75;padding:10px;border-radius:8px}
.warn{background:#fef3c7;border:1px solid #fde68a;color:#7c2d12;padding:10px;border-radius:8px}
a{color:#2563eb}
ul{margin-left:20px}
</style>
</head>
<body>
<header><h1>Doorman Field Guide</h1></header>
<main>
<p>This guide explains sensitive fields and common configurations with examples.</p>
<h2 id="apis">APIs</h2>
<p><strong>API Name/Version</strong> define the base path clients call: <code>/api/rest/&lt;name&gt;/&lt;version&gt;/...</code>.</p>
<div class="tip">Example: name <code>users</code>, version <code>v1</code> → client calls <code>/api/rest/users/v1/list</code></div>
<h3 id="api-config">Configuration</h3>
<ul>
<li><strong>Credits Enabled</strong>: deducts credits before proxying; configure a <em>Credit Group</em> that injects an API key header.</li>
<li><strong>Authorization Field Swap</strong>: maps inbound <code>Authorization</code> into a custom upstream header (e.g., <code>X-Api-Key</code>).</li>
<li><strong>Allowed Headers</strong>: restrict which upstream response headers are forwarded back (use lowercase names).</li>
</ul>
<pre>
# Example curl
curl -H "Authorization: Bearer ..." \
http://localhost:5001/api/rest/users/v1/list
</pre>
<h3 id="servers">Servers</h3>
<p>Add one or more upstream base URLs (scheme + host + port). Endpoint-level servers can override. Selection defaults to round-robin.</p>
<h3 id="access-control">Access Control</h3>
<p>Access requires BOTH an allowed <em>role</em> and membership in ANY allowed <em>group</em>.</p>
<h2 id="endpoints">Endpoints</h2>
<p>Define <strong>Method</strong> and <strong>URI</strong> relative to the API base. Use <code>{param}</code> syntax for path variables.</p>
<div class="tip">Example: <code>GET /items/{id}</code> matches <code>/api/rest/users/v1/items/123</code></div>
<p>Enable <strong>Endpoint Servers</strong> to override API servers for this endpoint only.</p>
<h2 id="routing">Routing</h2>
<p>Create named routing sets with an ordered list of upstreams. Doorman may choose an upstream based on client key, method, and policies.</p>
<h2 id="users">Users</h2>
<ul>
<li><strong>Password</strong>: minimum 16 chars with upper/lower/digit/symbol.</li>
<li><strong>Role</strong>: determines platform permissions (e.g., manage_apis, view_logs).</li>
<li><strong>UI Access</strong>: controls login to admin UI; API access is independent.</li>
<li><strong>Groups</strong>: used in API group checks (see Access Control).</li>
</ul>
<h3 id="rate-limit">Rate Limiting</h3>
<p>Limits requests per user over a time window (e.g., 100 per minute). Exceeding limits returns 429.</p>
<h3 id="throttle">Throttling</h3>
<p>Controls burst behavior with <em>duration</em>, <em>wait</em>, and optional <em>queue size</em>.</p>
<ul>
<li><strong>Throttle Duration</strong>: period length before reset.</li>
<li><strong>Wait Duration</strong>: how long requests wait when throttled before retry.</li>
<li><strong>Queue Limit</strong>: max queued requests; null disables queuing.</li>
</ul>
<h2 id="credits">Credits</h2>
<p>Credit definitions specify a <strong>credit group</strong>, an API key header name, a default API key value, and one or more tiers.</p>
<ul>
<li><strong>API Credit Group</strong>: reference name used by APIs to deduct credits and inject keys.</li>
<li><strong>API Key Header</strong>: header name injected when proxying (e.g., <code>x-api-key</code>).</li>
<li><strong>API Key</strong>: default key used when proxying; users can also have per-user keys.</li>
<li><strong>Tiers</strong>: JSON array with <code>tier_name</code>, <code>credits</code>, <code>input_limit</code>, <code>output_limit</code>, and <code>reset_frequency</code>.</li>
</ul>
<h2 id="security">Security</h2>
<p><strong>Auto-save</strong> writes encrypted memory dumps periodically (requires <code>MEM_ENCRYPTION_KEY</code>). <strong>Dump Path</strong> stores the file; use an encrypted volume.
<div class="warn">Restart is required to apply server TLS changes when running with built-in HTTPS. Use an edge proxy for zero-downtime rotation.</div>
<h2 id="auth-admin">Auth Admin</h2>
<p>Check status, revoke tokens, and enable/disable users. Revocation immediately invalidates tokens; enable/disable toggles login capability.</p>
<h2 id="examples">Examples</h2>
<pre>
# REST call through Doorman
curl -H "Authorization: Bearer &lt;token&gt;" \
-H "client-key: demo-client" \
http://localhost:5001/api/rest/users/v1/items/42
# GraphQL
curl -H "Authorization: Bearer &lt;token&gt;" \
-H "X-API-Version: v1" \
-H "Content-Type: application/json" \
-d '{"query":"{ hello }"}' \
http://localhost:5001/api/graphql/example
</pre>
</main>
</body>
</html>

View File

@@ -4,6 +4,8 @@ import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { SERVER_URL } from '@/utils/config'
import { postJson } from '@/utils/api'
@@ -100,6 +102,9 @@ export default function AddEndpointPage() {
<div className="card max-w-3xl">
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2 -mt-2 mb-2">
<FormHelp docHref="/docs/using-fields.html#endpoints">Define method and URI; optional upstream override per endpoint.</FormHelp>
</div>
<div>
<label className="block text-sm font-medium mb-1">Method</label>
<select className="input" value={method} onChange={e => setMethod(e.target.value)}>
@@ -107,7 +112,10 @@ export default function AddEndpointPage() {
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">URI</label>
<label className="block text-sm font-medium mb-1">
URI
<InfoTooltip text="Path pattern relative to the API base. Use {param} for path variables. Example: /items/{id}" />
</label>
<input className="input" value={uri} onChange={e => setUri(e.target.value)} placeholder="/path/{id}" />
</div>
<div className="md:col-span-2">
@@ -118,7 +126,10 @@ export default function AddEndpointPage() {
<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>
<label htmlFor="use-override" className="text-sm">
Use endpoint servers (override API servers)
<InfoTooltip text="Provide endpoint-specific upstreams. If disabled or empty, the API-level servers are used." />
</label>
</div>
<div className={`flex gap-2 ${useOverride ? '' : 'opacity-60'}`}>
<input

View File

@@ -740,10 +740,11 @@ const ApiDetailPage = () => {
</div>
{api.api_credits_enabled && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Credit Group
</label>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Credit Group
<InfoTooltip text="Configured credit group name used to deduct and inject keys." />
</label>
{isEditing ? (
<input
type="text"
@@ -761,6 +762,7 @@ const ApiDetailPage = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Authorization Field Swap
<InfoTooltip text="Map Authorization to a different header (e.g., X-Api-Key)." />
</label>
{isEditing ? (
<input
@@ -778,6 +780,7 @@ const ApiDetailPage = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Use Protobuf
<InfoTooltip text="Frontend preference; enables proto-aware UI only." />
</label>
{isEditing ? (
<div className="flex items-center">
@@ -902,8 +905,9 @@ const ApiDetailPage = () => {
{/* Allowed Groups */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Allowed Groups</h3>
<FormHelp docHref="/docs/using-fields.html#access-control">User must belong to any listed group.</FormHelp>
</div>
<div className="p-6 space-y-4">
{isEditing && (
@@ -951,6 +955,7 @@ const ApiDetailPage = () => {
<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>
<FormHelp docHref="/docs/using-fields.html#servers">Add base upstreams; include scheme and port.</FormHelp>
</div>
<div className="p-6 space-y-4">
{isEditing && (
@@ -1067,8 +1072,9 @@ const ApiDetailPage = () => {
{/* Allowed Headers */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Allowed Headers</h3>
<FormHelp docHref="/docs/using-fields.html#header-forwarding">Forward only selected upstream response headers.</FormHelp>
</div>
<div className="p-6 space-y-4">
{isEditing && (

View File

@@ -4,6 +4,8 @@ import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { SERVER_URL } from '@/utils/config'
import { postJson } from '@/utils/api'
@@ -153,8 +155,9 @@ const AddApiPage = () => {
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Basic Information</h3>
<FormHelp docHref="/docs/using-fields.html#apis">Fill API name/version; these form the base path clients call.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -194,7 +197,7 @@ const AddApiPage = () => {
disabled={loading}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
API version (e.g., v1, v2, beta)
API version (e.g., v1, v2)
</p>
</div>
</div>
@@ -243,12 +246,16 @@ const AddApiPage = () => {
</div>
<div className="card">
<div className="card-header"><h3 className="card-title">Configuration</h3></div>
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Configuration</h3>
<FormHelp docHref="/docs/using-fields.html#api-config">Set credits, auth header mapping, and validations.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Credits Enabled
<InfoTooltip text="When enabled, each request to this API deducts credits from the caller's assigned credit group before forwarding upstream." />
</label>
<div className="flex items-center">
<input
@@ -269,6 +276,7 @@ const AddApiPage = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Credit Group
<InfoTooltip text="Name of a configured credit group (e.g., ai-basic). Determines where to deduct and which API key header to inject." />
</label>
<input
type="text"
@@ -283,16 +291,25 @@ const AddApiPage = () => {
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Authorization Field Swap</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Authorization Field Swap
<InfoTooltip text="Map inbound Authorization header into a different header name expected by the upstream service. Example: X-Api-Key." />
</label>
<input type="text" name="api_authorization_field_swap" className="input" placeholder="backend-auth-header" value={formData.api_authorization_field_swap} onChange={handleChange} disabled={loading} />
</div>
</div>
</div>
<div className="card">
<div className="card-header"><h3 className="card-title">Servers</h3></div>
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Servers</h3>
<FormHelp docHref="/docs/using-fields.html#servers">Add one or more upstream base URLs used for proxying.</FormHelp>
</div>
<div className="p-6 space-y-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">API Servers</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Servers
<InfoTooltip text="Base URLs for upstreams. Include scheme and port. Example: http://localhost:8080" />
</label>
<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>
@@ -317,11 +334,15 @@ const AddApiPage = () => {
</div>
<div className="card">
<div className="card-header"><h3 className="card-title">Allowed Roles</h3></div>
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Allowed Roles</h3>
<FormHelp docHref="/docs/using-fields.html#access-control">Grant access by platform roles and groups.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Allowed Roles
<InfoTooltip text="Only users with any of these platform roles can access this API." />
</label>
<div className="flex gap-2">
<input
@@ -351,6 +372,7 @@ const AddApiPage = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Use Protobuf
<InfoTooltip text="Frontend preference enabling proto-aware features (e.g., proto editor). Does not affect gateway behavior." />
</label>
<div className="flex items-center">
<input
@@ -372,11 +394,15 @@ const AddApiPage = () => {
</div>
<div className="card">
<div className="card-header"><h3 className="card-title">Allowed Groups</h3></div>
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Allowed Groups</h3>
<FormHelp docHref="/docs/using-fields.html#access-control">Restrict by user groups; use ALL to allow any group.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Allowed Groups
<InfoTooltip text="User must belong to any of these groups to access this API." />
</label>
<div className="flex gap-2">
<input
@@ -407,10 +433,16 @@ const AddApiPage = () => {
</div>
<div className="card">
<div className="card-header"><h3 className="card-title">Allowed Headers</h3></div>
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Allowed Headers</h3>
<FormHelp docHref="/docs/using-fields.html#header-forwarding">Choose which upstream response headers are forwarded.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Allowed Headers</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Allowed Headers
<InfoTooltip text="Response headers from upstream that Doorman may forward back to the client. Use lowercase names; examples: x-rate-limit, retry-after." />
</label>
<div className="flex gap-2">
<input type="text" className="input flex-1" placeholder="e.g., Authorization" value={newHeader} onChange={(e) => setNewHeader(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && addHeader()} disabled={loading} />
<button type="button" onClick={addHeader} className="btn btn-secondary" disabled={loading}>Add</button>

View File

@@ -2,6 +2,7 @@
import React, { useEffect, useState } from 'react'
import Layout from '@/components/Layout'
import FormHelp from '@/components/FormHelp'
import { SERVER_URL } from '@/utils/config'
import { getJson } from '@/utils/api'
import { ProtectedRoute } from '@/components/ProtectedRoute'
@@ -86,6 +87,7 @@ export default function AuthAdminPage() {
<div className="card">
<div className="p-6 space-y-4">
<FormHelp docHref="/docs/using-fields.html#auth-admin">Look up user status, revoke tokens, and enable/disable accounts.</FormHelp>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium">Username</label>

View File

@@ -4,6 +4,8 @@ import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { SERVER_URL } from '@/utils/config'
import { getJson, putJson, delJson } from '@/utils/api'
import { ProtectedRoute } from '@/components/ProtectedRoute'
@@ -79,23 +81,24 @@ export default function EditCreditDefPage() {
<div className="card">
<div className="p-6 space-y-4">
<FormHelp docHref="/docs/using-fields.html#credits">Edit key header, optional key, and tiers. Changes apply immediately.</FormHelp>
{error && <div className="text-sm text-error-600">{error}</div>}
{success && <div className="text-sm text-success-600">{success}</div>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium">API Credit Group</label>
<label className="block text-sm font-medium">API Credit Group <InfoTooltip text="Immutable group name used by APIs" /></label>
<input className="input" value={api_credit_group} readOnly />
</div>
<div>
<label className="block text-sm font-medium">API Key Header</label>
<label className="block text-sm font-medium">API Key Header <InfoTooltip text="Header name injected when proxying (e.g., x-api-key)" /></label>
<input className="input" value={api_key_header} onChange={e => setHeader(e.target.value)} />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium">API Key (leave blank to keep existing)</label>
<label className="block text-sm font-medium">API Key (leave blank to keep existing) <InfoTooltip text="Optional default key value; leave empty to keep current" /></label>
<input className="input" value={api_key} onChange={e => setKey(e.target.value)} placeholder="sk_live_..." />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium">Credit Tiers (JSON)</label>
<label className="block text-sm font-medium">Credit Tiers (JSON) <InfoTooltip text="Array of tiers with tier_name, credits, input_limit, output_limit, reset_frequency" /></label>
<textarea className="input font-mono text-xs h-48" value={creditTiersText} onChange={e => setTiersText(e.target.value)} />
</div>
</div>

View File

@@ -3,6 +3,8 @@
import React, { useState } from 'react'
import Link from 'next/link'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { SERVER_URL } from '@/utils/config'
import { postJson } from '@/utils/api'
import { ProtectedRoute } from '@/components/ProtectedRoute'
@@ -44,23 +46,24 @@ export default function AddCreditDefPage() {
<div className="card">
<div className="p-6 space-y-4">
<FormHelp docHref="/docs/using-fields.html#credits">Define a credit group, key header, optional key, and tiers.</FormHelp>
{error && <div className="text-sm text-error-600">{error}</div>}
{success && <div className="text-sm text-success-600">{success}</div>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium">API Credit Group</label>
<label className="block text-sm font-medium">API Credit Group <InfoTooltip text="Reference name used by APIs to deduct credits and inject keys." /></label>
<input className="input" value={api_credit_group} onChange={e => setGroup(e.target.value)} placeholder="ai-basic" />
</div>
<div>
<label className="block text-sm font-medium">API Key Header</label>
<label className="block text-sm font-medium">API Key Header <InfoTooltip text="Header name injected when proxying requests (e.g., x-api-key)." /></label>
<input className="input" value={api_key_header} onChange={e => setHeader(e.target.value)} placeholder="x-api-key" />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium">API Key</label>
<label className="block text-sm font-medium">API Key <InfoTooltip text="Default key used when proxying; users can also have per-user keys." /></label>
<input className="input" value={api_key} onChange={e => setKey(e.target.value)} placeholder="sk_live_..." />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium">Credit Tiers (JSON)</label>
<label className="block text-sm font-medium">Credit Tiers (JSON) <InfoTooltip text="Array of tiers with tier_name, credits, input_limit, output_limit, reset_frequency." /></label>
<textarea className="input font-mono text-xs h-48" value={creditTiersText} onChange={e => setTiersText(e.target.value)} />
</div>
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
import { ProtectedRoute } from '@/components/ProtectedRoute'
@@ -20,6 +20,10 @@ export default function UserCreditsDetailPage() {
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [addGroupName, setAddGroupName] = useState('')
const [addGroupTier, setAddGroupTier] = useState('')
const [addGroupCredits, setAddGroupCredits] = useState<number | ''>('')
const [addResetDate, setAddResetDate] = useState('')
const loadDefs = async () => {
try {
@@ -68,20 +72,63 @@ export default function UserCreditsDetailPage() {
} finally { setSaving(false) }
}
const availableGroupsToAdd = useMemo(() => {
const current = new Set(Object.keys(userCredits || {}))
return Object.keys(defs || {}).filter(g => !current.has(g))
}, [defs, userCredits])
// Inline validation: available must be within [0, total]
const invalidGroups = useMemo(() => {
const bad: string[] = []
for (const [group, info] of Object.entries(userCredits || {})) {
const tier = (info as any).tier_name
const total = defs[group]?.[tier || '']?.credits || 0
const available = Number((info as any).available_credits || 0)
if (available < 0 || (total > 0 && available > total)) {
bad.push(group)
}
}
return bad
}, [userCredits, defs])
const addGroupToUser = () => {
if (!addGroupName) return
const tier = addGroupTier || Object.keys(defs[addGroupName] || {})[0]
const total = defs[addGroupName]?.[tier || '']?.credits || 0
let avail = typeof addGroupCredits === 'number' ? addGroupCredits : total
if (avail < 0) avail = 0
if (total > 0 && avail > total) avail = total
setUserCredits(prev => ({
...prev,
[addGroupName]: {
tier_name: tier,
available_credits: avail,
reset_date: addResetDate || '',
}
}))
setAddGroupName(''); setAddGroupTier(''); setAddGroupCredits(''); setAddResetDate('')
}
const removeGroupFromUser = (group: string) => {
const next = { ...userCredits }
delete next[group]
setUserCredits(next)
}
return (
<ProtectedRoute requiredPermission="manage_credits">
<Layout>
<div className="space-y-6">
<div className="page-header">
<div>
<h1 className="page-title">Credits for {uname}</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">View and edit per-group credit allocations</p>
</div>
<div className="flex gap-2">
<div className="page-header">
<div>
<h1 className="page-title">Credits for {uname}</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">View and edit per-group credit allocations</p>
</div>
<div className="flex gap-2">
<button onClick={() => router.push('/credits')} className="btn btn-secondary">Back to Credits</button>
<button onClick={save} disabled={saving} className="btn btn-primary">{saving ? 'Saving…' : 'Save'}</button>
<button onClick={save} disabled={saving || invalidGroups.length > 0} className="btn btn-primary">{saving ? 'Saving…' : (invalidGroups.length>0 ? 'Fix Errors to Save' : 'Save')}</button>
</div>
</div>
</div>
{error && <div className="rounded-md bg-error-50 border border-error-200 p-3 text-error-700 text-sm">{error}</div>}
{success && <div className="rounded-md bg-success-50 border border-success-200 p-3 text-success-700 text-sm">{success}</div>}
@@ -89,69 +136,142 @@ export default function UserCreditsDetailPage() {
{loading ? (
<div className="card"><div className="p-6 text-gray-500">Loading</div></div>
) : (
<div className="card">
<div className="p-6 space-y-3">
{Object.entries(userCredits).length === 0 && (
<div className="text-gray-500">No credit groups for this user</div>
)}
{Object.entries(userCredits).map(([group, info]) => {
const tierMap = defs[group] || {}
const meta = tierMap[(info as any).tier_name] || { credits: 0, reset_frequency: undefined }
const total = meta.credits || 0
const available = Number((info as any).available_credits || 0)
const used = total > 0 ? Math.max(total - available, 0) : 0
return (
<div key={group} className="grid grid-cols-1 md:grid-cols-12 items-center gap-2">
<div className="md:col-span-2">
<span className="badge badge-gray w-full justify-center">{group}</span>
</div>
<div className="md:col-span-2 text-sm">
<div className="text-gray-600">Tier</div>
<div className="font-medium">{(info as any).tier_name || '-'}</div>
</div>
<div className="md:col-span-2 text-sm">
<div className="text-gray-600">Total</div>
<div className="font-medium">{total || 0}</div>
</div>
<div className="md:col-span-2 text-sm">
<div className="text-gray-600">Used</div>
<div className="font-medium">{used}</div>
</div>
<div className="md:col-span-2 text-sm">
<div className="text-gray-600">Left</div>
<div className="font-medium">{available}</div>
</div>
<div className="md:col-span-1 text-sm">
<div className="text-gray-600">Reset Freq</div>
<div className="font-medium">{meta.reset_frequency || '-'}</div>
</div>
<div className="md:col-span-1 text-sm">
<div className="text-gray-600">Reset Date</div>
<div className="font-medium">{(info as any).reset_date || '-'}</div>
</div>
<div className="md:col-span-12 flex items-center gap-2">
<input
className="input w-32"
type="number"
value={(info as any).available_credits}
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], available_credits: Number(e.target.value || 0) } }))}
/>
<input
className="input flex-1"
placeholder="user API key (optional)"
value={(info as any).user_api_key || ''}
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], user_api_key: e.target.value } }))}
/>
<>
{/* Add credit group */}
<div className="card">
<div className="card-header"><h3 className="card-title">Add Credit Group</h3></div>
<div className="p-6 grid grid-cols-1 md:grid-cols-6 gap-3 items-end">
<div>
<label className="block text-sm font-medium mb-1">Group</label>
<select className="input" value={addGroupName} onChange={e => { setAddGroupName(e.target.value); setAddGroupTier('') }}>
<option value="">Select group</option>
{availableGroupsToAdd.map(g => (
<option key={g} value={g}>{g}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tier</label>
<select className="input" value={addGroupTier} onChange={e => setAddGroupTier(e.target.value)} disabled={!addGroupName}>
<option value="">{addGroupName ? 'Select tier' : 'Select group first'}</option>
{addGroupName && Object.keys(defs[addGroupName] || {}).map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Available Credits</label>
<input className="input" type="number" value={addGroupCredits as any}
onChange={e => setAddGroupCredits(e.target.value === '' ? '' : Number(e.target.value))} placeholder="auto" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Tier Total</label>
<div className="flex items-center gap-2">
<div className="text-sm text-gray-700 dark:text-gray-300">
{addGroupName && (defs[addGroupName]?.[addGroupTier || Object.keys(defs[addGroupName]||{})[0]]?.credits || 0)}
</div>
<button className="btn btn-secondary" disabled={!addGroupName} onClick={() => {
const t = addGroupTier || Object.keys(defs[addGroupName] || {})[0]
const total = defs[addGroupName]?.[t || '']?.credits || 0
setAddGroupCredits(total)
}}>Use Full</button>
</div>
)
})}
{addGroupName && typeof addGroupCredits === 'number' && (() => {
const t = addGroupTier || Object.keys(defs[addGroupName] || {})[0]
const total = defs[addGroupName]?.[t || '']?.credits || 0
const bad = addGroupCredits < 0 || (total > 0 && addGroupCredits > total)
return bad ? <div className="text-xs text-error-600 mt-1">Must be between 0 and {total}</div> : null
})()}
</div>
<div>
<label className="block text-sm font-medium mb-1">Reset Date</label>
<input className="input" type="date" value={addResetDate} onChange={e => setAddResetDate(e.target.value)} />
</div>
<div>
<button className="btn btn-primary w-full" onClick={addGroupToUser} disabled={!addGroupName || (()=>{
if (addGroupName && typeof addGroupCredits === 'number'){
const t = addGroupTier || Object.keys(defs[addGroupName] || {})[0]
const total = defs[addGroupName]?.[t || '']?.credits || 0
return addGroupCredits < 0 || (total > 0 && addGroupCredits > total)
}
return false
})()}>Add</button>
</div>
</div>
</div>
</div>
{/* Existing allocations */}
<div className="card">
<div className="card-header"><h3 className="card-title">Allocations</h3></div>
<div className="p-6 space-y-4">
{Object.entries(userCredits).length === 0 && (
<div className="text-gray-500">No credit groups for this user</div>
)}
{Object.entries(userCredits).map(([group, info]) => {
const tierMap = defs[group] || {}
const currentTier = (info as any).tier_name
const meta = tierMap[currentTier] || { credits: 0, reset_frequency: undefined }
const total = meta.credits || 0
const available = Number((info as any).available_credits || 0)
const used = total > 0 ? Math.max(total - available, 0) : 0
const pct = total > 0 ? Math.min(100, Math.max(0, Math.round((used / total) * 100))) : 0
return (
<div key={group} className="border rounded-lg p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="badge badge-gray">{group}</span>
<div className="text-xs text-gray-500">Reset: {meta.reset_frequency || '-'} | Date: {(info as any).reset_date || '-'}</div>
</div>
<button className="btn btn-ghost text-error-600" onClick={() => removeGroupFromUser(group)}>Remove</button>
</div>
<div className="mt-3">
<div className="h-2 bg-gray-200 dark:bg-gray-800 rounded">
<div className="h-2 bg-primary-500 rounded" style={{ width: `${pct}%` }} />
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-400">{used} used of {total} · {available} left</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mt-4">
<div>
<label className="block text-sm font-medium mb-1">Tier</label>
<select className="input" value={(info as any).tier_name || ''}
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], tier_name: e.target.value } }))}>
{Object.keys(tierMap).map(t => (<option key={t} value={t}>{t}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Available Credits</label>
<div className="flex flex-wrap gap-2 items-center">
<input className={`input flex-1 ${invalidGroups.includes(group)?'border-error-500':''}`} type="number" value={(info as any).available_credits}
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], available_credits: Number(e.target.value || 0) } }))} />
<button className="btn btn-secondary" onClick={() => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], available_credits: Number((prev as any)[group].available_credits || 0) + 10 } }))}>+10</button>
<button className="btn btn-secondary" onClick={() => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], available_credits: Math.max(0, Number((prev as any)[group].available_credits || 0) - 10) } }))}>-10</button>
<button className="btn btn-secondary" onClick={() => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], available_credits: total } }))}>Full</button>
</div>
{invalidGroups.includes(group) && (
<div className="text-xs text-error-600 mt-1">Must be between 0 and {total}</div>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">Reset Date</label>
<input className="input" type="date" value={(info as any).reset_date || ''}
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], reset_date: e.target.value } }))} />
</div>
<div>
<label className="block text-sm font-medium mb-1">User API Key (optional)</label>
<input className="input" placeholder="user API key"
value={(info as any).user_api_key || ''}
onChange={e => setUserCredits(prev => ({ ...prev, [group]: { ...(prev as any)[group], user_api_key: e.target.value } }))} />
</div>
</div>
</div>
)
})}
</div>
</div>
</>
)}
</div>
</Layout>
</ProtectedRoute>
)
}

View File

@@ -5,6 +5,8 @@ import ConfirmModal from '@/components/ConfirmModal'
import Link from 'next/link'
import { useRouter, useParams } from 'next/navigation'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { fetchJson } from '@/utils/http'
import { SERVER_URL } from '@/utils/config'
@@ -287,13 +289,15 @@ const GroupDetailPage = () => {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Basic Information</h3>
<FormHelp docHref="/docs/using-fields.html#access-control">Groups gate API access alongside roles.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Group Name
<InfoTooltip text="Unique name for the group. Users can belong to multiple groups." />
</label>
{isEditing ? (
<input
@@ -329,8 +333,9 @@ const GroupDetailPage = () => {
{/* API Access */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">API Access</h3>
<FormHelp docHref="/docs/using-fields.html#access-control">Grant access to API name/version pairs (e.g., users/v1).</FormHelp>
</div>
<div className="p-6 space-y-4">
{isEditing && (

View File

@@ -4,6 +4,8 @@ import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { SERVER_URL } from '@/utils/config'
import { postJson } from '@/utils/api'
@@ -101,9 +103,11 @@ const AddGroupPage = () => {
{/* Form */}
<div className="card max-w-2xl">
<form onSubmit={handleSubmit} className="space-y-6">
<FormHelp docHref="/docs/using-fields.html#access-control">Groups gate API access alongside roles. Use clear names.</FormHelp>
<div>
<label htmlFor="group_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Group Name *
<InfoTooltip text="Unique name for the group. Users can belong to multiple groups." />
</label>
<input
type="text"
@@ -140,6 +144,7 @@ const AddGroupPage = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Access
<InfoTooltip text="Add API name/version pairs this group may access, e.g., users/v1" />
</label>
<div className="space-y-3">
<div className="flex gap-2">

View File

@@ -0,0 +1,163 @@
'use client'
import React, { useState } from 'react'
import Layout from '@/components/Layout'
import { SERVER_URL } from '@/utils/config'
import { postJson, delJson } from '@/utils/api'
import ConfirmModal from '@/components/ConfirmModal'
import { useToast } from '@/contexts/ToastContext'
export default function ImportExportPage() {
const toast = useToast()
const [exportWorking, setExportWorking] = useState<string | null>(null)
const [importWorking, setImportWorking] = useState(false)
const [conflictQueue, setConflictQueue] = useState<{ api_name: string; api_version: string }[]>([])
const [currentConflict, setCurrentConflict] = useState<{ api_name: string; api_version: string } | null>(null)
const [pendingImport, setPendingImport] = useState<any>(null)
const [showConflictModal, setShowConflictModal] = useState(false)
const startExport = async (key: string, path: string) => {
try {
setExportWorking(key)
const res = await fetch(`${SERVER_URL}${path}`, { credentials: 'include' })
const data = await res.json()
const payload = data?.response || data
// Ensure no sensitive fields (none are included by server for these types)
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `doorman-${key}-export.json`
a.click()
URL.revokeObjectURL(a.href)
} catch (e:any) {
toast.error(e?.message || 'Export failed')
} finally {
setExportWorking(null)
}
}
const onImportFile = async (file?: File | null) => {
if (!file) return
try {
setImportWorking(true)
const text = await file.text()
const obj = JSON.parse(text)
// Detect API conflicts only (roles/groups/routings/endpoints are upserted safely)
const apis: { api_name: string; api_version: string }[] = (obj?.apis || []).map((a:any) => ({ api_name: a.api_name, api_version: a.api_version }))
const conflicts: { api_name: string; api_version: string }[] = []
for (const a of apis) {
try {
const res = await fetch(`${SERVER_URL}/platform/api/${encodeURIComponent(a.api_name)}/${encodeURIComponent(a.api_version)}`, { credentials: 'include' })
if (res.ok) conflicts.push(a)
} catch {}
}
setPendingImport(obj)
if (conflicts.length > 0) {
setConflictQueue(conflicts)
setCurrentConflict(conflicts[0])
setShowConflictModal(true)
} else {
await postJson(`${SERVER_URL}/platform/config/import`, obj)
toast.success('Import completed')
}
} catch (e:any) {
toast.error(e?.message || 'Invalid import file')
} finally {
setImportWorking(false)
}
}
const advanceConflict = async () => {
const queue = [...conflictQueue]
queue.shift()
if (queue.length === 0) {
setShowConflictModal(false)
try {
if (pendingImport) {
await postJson(`${SERVER_URL}/platform/config/import`, pendingImport)
toast.success('Import completed')
}
} catch (e:any) {
toast.error(e?.message || 'Import failed')
} finally {
setPendingImport(null)
setCurrentConflict(null)
setConflictQueue([])
}
return
}
setConflictQueue(queue)
setCurrentConflict(queue[0])
setShowConflictModal(true)
}
return (
<Layout>
<div className="space-y-6">
<div className="page-header">
<div>
<h1 className="page-title">Import / Export</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">Bulk import or export platform configuration</p>
</div>
</div>
<div className="card">
<div className="card-header"><h3 className="card-title">Export</h3></div>
<div className="p-6 space-y-3">
<div className="flex flex-wrap gap-2">
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('all', '/platform/config/export/all')}>{exportWorking==='all'?'Exporting…':'Export All'}</button>
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('apis', '/platform/config/export/apis')}>{exportWorking==='apis'?'Exporting…':'Export APIs'}</button>
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('endpoints', '/platform/config/export/endpoints')}>{exportWorking==='endpoints'?'Exporting…':'Export Endpoints'}</button>
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('roles', '/platform/config/export/roles')}>{exportWorking==='roles'?'Exporting…':'Export Roles'}</button>
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('groups', '/platform/config/export/groups')}>{exportWorking==='groups'?'Exporting…':'Export Groups'}</button>
<button className="btn btn-secondary" disabled={!!exportWorking} onClick={() => startExport('routings', '/platform/config/export/routings')}>{exportWorking==='routings'?'Exporting…':'Export Routings'}</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">Exports never include passwords, tokens, or secrets.</p>
</div>
</div>
<div className="card">
<div className="card-header"><h3 className="card-title">Import</h3></div>
<div className="p-6 space-y-3">
<input type="file" accept="application/json,.json" onChange={(e)=> onImportFile(e.target.files?.[0])} />
<p className="text-xs text-gray-500 dark:text-gray-400">Upload a JSON containing any of: apis, endpoints, roles, groups, routings. Conflicting APIs will prompt you to keep system or replace with your upload.</p>
</div>
</div>
<ConfirmModal
open={showConflictModal}
title="API Conflict Detected"
message={<div>
<p className="mb-2">API already exists: <code className="font-mono">{currentConflict?.api_name}/{currentConflict?.api_version}</code></p>
<p className="text-sm">Choose which version to keep:</p>
<ul className="list-disc ml-6 mt-2 text-sm">
<li><b>Keep System</b>: keep the existing API; skip this API from import.</li>
<li><b>Use Upload</b>: permanently delete the existing API, then import the uploaded version.</li>
</ul>
</div>}
confirmLabel="Use Upload (Delete System)"
cancelLabel="Keep System"
onCancel={async () => {
if (!currentConflict || !pendingImport) { setShowConflictModal(false); return }
const { api_name, api_version } = currentConflict
const next = { ...pendingImport, apis: (pendingImport.apis || []).filter((a:any) => !(a.api_name === api_name && a.api_version === api_version)) }
setPendingImport(next)
advanceConflict()
}}
onConfirm={async () => {
try {
if (currentConflict) {
await delJson(`${SERVER_URL}/platform/api/${encodeURIComponent(currentConflict.api_name)}/${encodeURIComponent(currentConflict.api_version)}`)
}
} catch (e:any) {
toast.error(e?.message || 'Failed to delete existing API')
} finally {
advanceConflict()
}
}}
/>
</div>
</Layout>
)
}

View File

@@ -5,6 +5,7 @@ import ConfirmModal from '@/components/ConfirmModal'
import Link from 'next/link'
import { useRouter, useParams } from 'next/navigation'
import Layout from '@/components/Layout'
import FormHelp from '@/components/FormHelp'
import { fetchJson } from '@/utils/http'
import { SERVER_URL } from '@/utils/config'
@@ -279,8 +280,9 @@ const RoleDetailPage = () => {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Basic Information</h3>
<FormHelp docHref="/docs/using-fields.html#roles">Update role name/description used for platform permissions.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div>
@@ -332,8 +334,9 @@ const RoleDetailPage = () => {
{/* Permissions */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Permissions</h3>
<FormHelp docHref="/docs/using-fields.html#access-control">Grant least-privilege access to platform features.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-1 gap-3">

View File

@@ -4,6 +4,7 @@ import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
import FormHelp from '@/components/FormHelp'
import { SERVER_URL } from '@/utils/config'
import { postJson } from '@/utils/api'
@@ -119,6 +120,7 @@ const AddRolePage = () => {
{/* Form */}
<div className="card max-w-2xl">
<form onSubmit={handleSubmit} className="space-y-6">
<FormHelp docHref="/docs/using-fields.html#roles">Define a role and toggle platform permissions. Apply least privilege.</FormHelp>
<div>
<label htmlFor="role_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Role Name *

View File

@@ -5,6 +5,8 @@ import ConfirmModal from '@/components/ConfirmModal'
import Link from 'next/link'
import { useRouter, useParams } from 'next/navigation'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { fetchJson } from '@/utils/http'
import { SERVER_URL } from '@/utils/config'
@@ -316,8 +318,9 @@ const RoutingDetailPage = () => {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Basic Information</h3>
<FormHelp docHref="/docs/using-fields.html#routing">Update name, description, and fixed server index.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div>
@@ -366,6 +369,7 @@ const RoutingDetailPage = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Server Index
<InfoTooltip text="Optional fixed index into the server list; leave 0 for default selection." />
</label>
{isEditing ? (
<input
@@ -384,8 +388,9 @@ const RoutingDetailPage = () => {
{/* Servers Configuration */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Servers Configuration</h3>
<FormHelp docHref="/docs/using-fields.html#routing">Ordered upstreams used for this client key.</FormHelp>
</div>
<div className="p-6 space-y-4">
{isEditing && (

View File

@@ -4,6 +4,8 @@ import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { SERVER_URL } from '@/utils/config'
import { postJson } from '@/utils/api'
@@ -99,6 +101,7 @@ const AddRoutingPage = () => {
{/* Form */}
<div className="card max-w-2xl">
<form onSubmit={handleSubmit} className="space-y-6">
<FormHelp docHref="/docs/using-fields.html#routing">Create a routing set with ordered upstreams and optional fixed index.</FormHelp>
<div>
<label htmlFor="routing_name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Routing Name *
@@ -138,6 +141,7 @@ const AddRoutingPage = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Servers *
<InfoTooltip text="Ordered list of upstream base URLs (scheme + host + port). Selection defaults to round-robin." />
</label>
<div className="space-y-3">
<div className="flex gap-2">

View File

@@ -2,6 +2,8 @@
import React, { useState, useEffect } from 'react'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { SERVER_URL } from '@/utils/config'
import { getJson, postJson, putJson, delJson } from '@/utils/api'
import { ProtectedRoute } from '@/components/ProtectedRoute'
@@ -293,16 +295,22 @@ const SecurityPage = () => {
{/* Settings (always visible) */}
<div className="card">
<div className="p-6 space-y-6">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Memory & Security Settings</h3>
{memoryOnly && (
<span className="badge badge-gray">Memory Mode</span>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Memory & Security Settings</h3>
{memoryOnly && (
<span className="badge badge-gray">Memory Mode</span>
)}
</div>
<FormHelp docHref="/docs/using-fields.html#security">Configure encrypted memory dumps and clear caches safely.</FormHelp>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className={`block text-sm font-medium ${memoryOnly ? 'text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-300'}`}>Enable Auto-save</label>
<label className={`block text-sm font-medium ${memoryOnly ? 'text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-300'}`}>
Enable Auto-save
<InfoTooltip text="When enabled, Doorman periodically writes an encrypted memory dump to the configured path. Requires MEM_ENCRYPTION_KEY on the server. In memory-only mode this is always on." />
</label>
<div className={`flex items-center gap-3 ${memoryOnly ? 'opacity-60 cursor-not-allowed' : ''}`}>
<input
type="checkbox"
@@ -321,7 +329,10 @@ const SecurityPage = () => {
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Auto-save Frequency (seconds)</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Auto-save Frequency (seconds)
<InfoTooltip text="Minimum 60s. Choose a value that balances RPO vs. IO overhead. Applies only when auto-save is enabled." />
</label>
<input
type="number"
min={60}
@@ -335,7 +346,10 @@ const SecurityPage = () => {
</div>
<div className="space-y-2 md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Dump Path</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Dump Path
<InfoTooltip text="Filesystem path for the encrypted memory dump. Store on an encrypted volume if possible. Example: generated/memory_dump.bin" />
</label>
<input
type="text"
value={settings.dump_path}
@@ -356,7 +370,10 @@ const SecurityPage = () => {
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Restore From Path</label>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Restore From Path
<InfoTooltip text="Points to a previously saved encrypted dump file. Requires MEM_ENCRYPTION_KEY to decrypt and restore." />
</label>
<div className="flex gap-2">
<input
type="text"

View File

@@ -122,6 +122,8 @@ const SettingsPage = () => {
}
}
// no-op leftover cleanup (import UI moved to its own page)
return (
<Layout>
<div className="space-y-6">
@@ -327,6 +329,7 @@ const SettingsPage = () => {
</form>
</div>
)}
</div>
</Layout>
)

View File

@@ -5,6 +5,8 @@ import ConfirmModal from '@/components/ConfirmModal'
import Link from 'next/link'
import { useRouter, useParams } from 'next/navigation'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { fetchJson } from '@/utils/http'
import { PROTECTED_USERS, SERVER_URL } from '@/utils/config'
@@ -385,8 +387,9 @@ const UserDetailPage = () => {
)}
{/* Basic Information */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Basic Information</h3>
<FormHelp docHref="/docs/using-fields.html#users">Update identity, role, status, and UI access.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div>
@@ -426,6 +429,7 @@ const UserDetailPage = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Role
<InfoTooltip text="Platform role controls permissions (e.g., manage_apis, view_logs)." />
</label>
{isEditing ? (
<input
@@ -443,6 +447,7 @@ const UserDetailPage = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Status
<InfoTooltip text="Inactive users cannot authenticate until re-enabled." />
</label>
{isEditing ? (
<div className="flex items-center">
@@ -466,6 +471,7 @@ const UserDetailPage = () => {
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
UI Access
<InfoTooltip text="Controls access to the admin UI; API access is separate." />
</label>
{isEditing ? (
<div className="flex items-center">
@@ -490,8 +496,9 @@ const UserDetailPage = () => {
{/* Groups */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Groups</h3>
<FormHelp docHref="/docs/using-fields.html#access-control">Groups are used in API access checks alongside roles.</FormHelp>
</div>
<div className="p-6 space-y-4">
{isEditing && (
@@ -541,8 +548,9 @@ const UserDetailPage = () => {
{/* Rate Limiting */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Rate Limiting</h3>
<FormHelp docHref="/docs/using-fields.html#rate-limit">Limits requests per user over a time window.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div>
@@ -581,8 +589,9 @@ const UserDetailPage = () => {
{/* Throttling */}
<div className="card">
<div className="card-header">
<div className="card-header flex items-center justify-between">
<h3 className="card-title">Throttling</h3>
<FormHelp docHref="/docs/using-fields.html#throttle">Control bursts with duration, wait, and queue size.</FormHelp>
</div>
<div className="p-6 space-y-4">
<div>

View File

@@ -4,6 +4,8 @@ import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
import InfoTooltip from '@/components/InfoTooltip'
import FormHelp from '@/components/FormHelp'
import { SERVER_URL } from '@/utils/config'
import { postJson } from '@/utils/api'
@@ -166,7 +168,10 @@ const AddUserPage = () => {
<form onSubmit={handleSubmit} className="space-y-8">
{/* Basic Information */}
<div className="space-y-6">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Basic Information</h3>
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Basic Information</h3>
<FormHelp docHref="/docs/using-fields.html#users">Create user credentials, set role and UI access.</FormHelp>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -203,6 +208,7 @@ const AddUserPage = () => {
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Password *
<InfoTooltip text="Minimum 16 chars with upper, lower, digit, and symbol for strong security." />
</label>
<input
type="password"
@@ -225,6 +231,7 @@ const AddUserPage = () => {
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Role *
<InfoTooltip text="Platform role controls permissions (e.g., manage_apis, view_logs)." />
</label>
<input
type="text"
@@ -242,6 +249,7 @@ const AddUserPage = () => {
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Status
<InfoTooltip text="Inactive users cannot authenticate until re-enabled." />
</label>
<select
id="status"
@@ -268,13 +276,17 @@ const AddUserPage = () => {
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Controls access to the admin UI; API access is separate.</p>
</div>
</div>
</div>
{/* Groups */}
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Groups</h3>
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Groups</h3>
<FormHelp docHref="/docs/using-fields.html#access-control">Groups are used for API access checks alongside roles.</FormHelp>
</div>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{formData.groups.map((group, index) => (
@@ -312,11 +324,15 @@ const AddUserPage = () => {
{/* Rate Limiting */}
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Rate Limiting</h3>
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Rate Limiting</h3>
<FormHelp docHref="/docs/using-fields.html#rate-limit">Limits requests per user over a time window.</FormHelp>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="rate_limit_duration" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Rate Limit Duration
<InfoTooltip text="Numeric window size (e.g., 100) combined with type (minute/hour)." />
</label>
<input
type="number"
@@ -332,6 +348,7 @@ const AddUserPage = () => {
<div>
<label htmlFor="rate_limit_duration_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Rate Limit Type
<InfoTooltip text="Unit for the rate limit window. Example: minute." />
</label>
<select
id="rate_limit_duration_type"
@@ -352,11 +369,15 @@ const AddUserPage = () => {
{/* Throttling */}
<div className="space-y-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Throttling</h3>
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Throttling</h3>
<FormHelp docHref="/docs/using-fields.html#throttle">Control burst behavior with wait, duration, and queue size.</FormHelp>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="throttle_duration" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Throttle Duration
<InfoTooltip text="How long a throttle period lasts before resetting." />
</label>
<input
type="number"
@@ -372,6 +393,7 @@ const AddUserPage = () => {
<div>
<label htmlFor="throttle_duration_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Throttle Type
<InfoTooltip text="Unit for the throttle duration period." />
</label>
<select
id="throttle_duration_type"
@@ -390,6 +412,7 @@ const AddUserPage = () => {
<div>
<label htmlFor="throttle_wait_duration" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Wait Duration
<InfoTooltip text="How long requests wait when throttled before retrying." />
</label>
<input
type="number"
@@ -405,6 +428,7 @@ const AddUserPage = () => {
<div>
<label htmlFor="throttle_wait_duration_type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Wait Type
<InfoTooltip text="Unit for the wait duration (seconds/minutes/etc)." />
</label>
<select
id="throttle_wait_duration_type"
@@ -423,6 +447,7 @@ const AddUserPage = () => {
<div>
<label htmlFor="throttle_queue_limit" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Queue Limit
<InfoTooltip text="Maximum number of queued requests when throttled; null disables queueing." />
</label>
<input
type="number"

View File

@@ -0,0 +1,24 @@
"use client"
import React from 'react'
interface Props {
docHref: string
children: React.ReactNode
className?: string
}
export default function FormHelp({ docHref, children, className }: Props) {
return (
<div className={`text-xs text-gray-600 dark:text-gray-400 flex items-center gap-2 ${className||''}`}>
<svg className="h-4 w-4 text-gray-500 dark:text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden>
<path fillRule="evenodd" d="M18 10A8 8 0 11.001 9.999 8 8 0 0118 10zM9 9a1 1 0 112 0v5a1 1 0 11-2 0V9zm1-6a1.5 1.5 0 100 3 1.5 1.5 0 000-3z" clipRule="evenodd"/>
</svg>
<span>{children}</span>
<a href={docHref} target="_blank" rel="noreferrer" className="text-primary-600 hover:text-primary-700 dark:text-primary-400 underline">
Learn more
</a>
</div>
)
}

View File

@@ -0,0 +1,36 @@
"use client"
import React, { useState } from 'react'
interface InfoTooltipProps {
text: string
className?: string
}
export default function InfoTooltip({ text, className }: InfoTooltipProps) {
const [open, setOpen] = useState(false)
return (
<span className={`relative inline-flex items-center ${className || ''}`}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<button
type="button"
aria-label="Help"
className="ml-1 inline-flex items-center justify-center h-4 w-4 rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 focus:outline-none"
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
<svg className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden>{/* info icon */}
<path fillRule="evenodd" d="M18 10A8 8 0 11.001 9.999 8 8 0 0118 10zM9 9a1 1 0 112 0v5a1 1 0 11-2 0V9zm1-6a1.5 1.5 0 100 3 1.5 1.5 0 000-3z" clipRule="evenodd"/>
</svg>
</button>
{open && (
<div className="absolute z-20 mt-2 w-64 p-2 text-xs rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200">
{text}
</div>
)}
</span>
)
}