- Add Endpoint
-
+ setAdding(true)}>Add Endpoint
}
>
{searchTerm || methodFilter !== 'ALL' ? 'Try adjusting your search terms or method filter.' : 'Get started by creating your first endpoint for this API.'}
@@ -548,7 +607,7 @@ export default function ApiEndpointsPage() {
- {ep.endpoint_method}
+ {ep.endpoint_method}
{ep.endpoint_uri}
diff --git a/web-client/src/app/apis/add/page.tsx b/web-client/src/app/apis/add/page.tsx
index d9c801d4..75711b56 100644
--- a/web-client/src/app/apis/add/page.tsx
+++ b/web-client/src/app/apis/add/page.tsx
@@ -12,6 +12,40 @@ import ConfirmModal from '@/components/ConfirmModal'
import { getJson } from '@/utils/api'
import SearchableSelect from '@/components/SearchableSelect'
+const STEPS = [
+ { id: 'basics', title: 'Basics', hint: 'Name and protocol' },
+ { id: 'upstream', title: 'Upstream', hint: 'Servers and routing' },
+ { id: 'access', title: 'Access', hint: 'Who can call it' },
+ { id: 'policies', title: 'Policies', hint: 'IP rules, headers, credits' },
+ { id: 'review', title: 'Review', hint: 'Confirm and create' },
+]
+
+function Field({ label, htmlFor, hint, tip, required, children }: { label: string; htmlFor?: string; hint?: React.ReactNode; tip?: string; required?: boolean; children: React.ReactNode }) {
+ return
+
{label}{required && * }{tip && }
+ {children}
+ {hint &&
{hint}
}
+
+}
+
+function Toggle({ id, name, checked, onChange, disabled, title, description, tip }: { id: string; name: string; checked: boolean; onChange: (e: React.ChangeEvent) => void; disabled?: boolean; title: string; description?: React.ReactNode; tip?: string }) {
+ return
+
+
+ {title}{tip && }
+ {description && {description} }
+
+
+}
+
+function Chip({ text, onRemove }: { text: string; onRemove: () => void }) {
+ return {text}×
+}
+
+function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
+ return {title} {description &&
{description}
}
{children}
+}
+
const AddApiPage = () => {
const router = useRouter()
const [loading, setLoading] = useState(false)
@@ -52,6 +86,7 @@ const AddApiPage = () => {
const [clientIpXff, setClientIpXff] = useState('')
const [protoFile, setProtoFile] = useState(null)
const [uploadProto, setUploadProto] = useState(false)
+ const [step, setStep] = useState(0)
React.useEffect(() => {
(async () => {
@@ -93,8 +128,8 @@ const AddApiPage = () => {
setIpWhitelistText(prev => (prev && prev.trim().length > 0) ? `${prev.trim()}\n${effectiveIp}` : effectiveIp)
}
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
+ const handleSubmit = async (e?: React.FormEvent) => {
+ e?.preventDefault()
setLoading(true)
setError(null)
@@ -115,7 +150,8 @@ const AddApiPage = () => {
if (!Array.isArray(payload.api_allowed_groups) || payload.api_allowed_groups.length === 0) {
payload.api_allowed_groups = ['ALL']
}
- await postJson(`${SERVER_URL}/platform/api`, payload)
+ const created = await postJson(`${SERVER_URL}/platform/api`, payload)
+ const createdApi = created?.api || created?.response?.api || created
// Upload proto file if provided
if (uploadProto && protoFile) {
@@ -135,9 +171,15 @@ const AddApiPage = () => {
}
}
- router.push('/apis')
+ const newId = createdApi?.api_id
+ if (newId) {
+ try { sessionStorage.setItem('selectedApi', JSON.stringify(createdApi)) } catch {}
+ router.push(`/apis/${encodeURIComponent(String(newId))}/endpoints?add=1`)
+ } else {
+ router.push('/apis')
+ }
} catch (err) {
- setError('Network error. Please try again.')
+ setError(err instanceof Error && err.message ? err.message : 'Unable to create the API. Please try again.')
} finally {
setLoading(false)
}
@@ -226,642 +268,242 @@ const AddApiPage = () => {
setFormData(prev => ({ ...prev, api_allowed_headers: prev.api_allowed_headers.filter((_, i) => i !== index) }))
}
+ const parseList = (t: string) => t.split(/\r?\n|,/).map(s => s.trim()).filter(Boolean)
+ const ipWarning = (() => {
+ const effectiveIp = (((formData as any).api_trust_x_forwarded_for && clientIpXff) ? clientIpXff : clientIp)
+ const wl = parseList(ipWhitelistText)
+ const bl = parseList(ipBlacklistText)
+ const isIPv6 = (s: string) => s.includes(':')
+ const toIPv4 = (s: string) => { const parts = s.split('.'); if (parts.length !== 4) return null as any; return parts.reduce((a, p) => (a << 8n) + (BigInt(parseInt(p, 10) & 255)), 0n) }
+ const expandIPv6 = (ip: string) => { if (ip.indexOf('::') !== -1) { const [h, t] = ip.split('::'); const hp = h ? h.split(':') : []; const tp = t ? t.split(':') : []; const missing = 8 - (hp.length + tp.length); return [...hp, ...Array(Math.max(0, missing)).fill('0'), ...tp].map(x => x || '0') } return ip.split(':') }
+ const toIPv6 = (s: string) => { const parts = expandIPv6(s); if (parts.length !== 8) return null as any; try { return parts.reduce((acc, h) => (acc << 16n) + BigInt(parseInt(h || '0', 16)), 0n) } catch { return null as any } }
+ const matches = (ip: string, patterns: string[]) => {
+ if (!ip) return false
+ const v6 = isIPv6(ip)
+ const ipVal = v6 ? toIPv6(ip) : toIPv4(ip)
+ return patterns.some(raw => {
+ const p = raw.trim(); if (!p) return false
+ if (p.includes('/')) {
+ const [net, maskStr] = p.split('/'); const m = parseInt(maskStr, 10)
+ const n6 = isIPv6(net); if (n6 !== v6) return false
+ const netVal = n6 ? toIPv6(net) : toIPv4(net); if (netVal === null || ipVal === null || isNaN(m as any)) return false
+ const bits = n6 ? 128 : 32
+ const shift = BigInt(bits - Math.min(Math.max(m, 0), bits))
+ const mask = ((1n << BigInt(bits)) - 1n) ^ ((1n << shift) - 1n)
+ return ((ipVal & mask) === (netVal & mask))
+ }
+ return p.toLowerCase() === ip.toLowerCase()
+ })
+ }
+ const warnWL = formData.api_ip_mode === 'whitelist' && wl.length > 0 && !matches(effectiveIp, wl)
+ const warnBL = matches(effectiveIp, bl)
+ if (!(warnWL || warnBL)) return null
+ return { text: warnBL ? 'Your current IP is in the blocked list. You may lose access after saving.' : 'Your current IP is not in the allowed list. You may lose access after saving.', ip: effectiveIp || 'unknown', xff: !!formData.api_trust_x_forwarded_for }
+ })()
+
+ const fd = formData as any
+ const nameOk = formData.api_name.trim().length > 0 && formData.api_version.trim().length > 0
+ const stepError = step === 0 && !nameOk ? 'Enter an API name and version to continue.' : null
+ const isLast = step === STEPS.length - 1
+ const goNext = () => { if (stepError) { setError(stepError); return } setError(null); setStep(s => Math.min(s + 1, STEPS.length - 1)) }
+ const goBack = () => { setError(null); setStep(s => Math.max(s - 1, 0)) }
+ const jump = (i: number) => { if (i > 0 && !nameOk) { setError('Enter an API name and version to continue.'); setStep(0); return } setError(null); setStep(i) }
+
+ const yesNo = (v: boolean) => (v ? 'Yes' : 'No')
+ const reviewRows: [string, React.ReactNode][] = [
+ ['Name / version', `${formData.api_name || '—'} / ${formData.api_version || '—'}`],
+ ['Protocol', formData.api_type],
+ ['Enabled on creation', yesNo(!!formData.active)],
+ ['Upstream servers', formData.api_servers.length ? formData.api_servers.join(', ') : 'None (add later)'],
+ ['Custom hostname', formData.api_hostname || '—'],
+ ['Retry attempts', String(formData.api_allowed_retry_count)],
+ ['Authentication required', yesNo(!!formData.api_auth_required)],
+ ['Public access', yesNo(!!fd.api_public)],
+ ['Allowed groups', formData.api_allowed_groups.join(', ') || 'ALL'],
+ ['Allowed roles', formData.api_allowed_roles.join(', ') || 'Any'],
+ ['IP policy', formData.api_ip_mode === 'whitelist' ? `Allow list only (${parseList(ipWhitelistText).length} entries)` : 'Allow all'],
+ ['Blocked IPs', String(parseList(ipBlacklistText).length)],
+ ['Credits', formData.api_credits_enabled ? `On (${formData.api_credit_group || 'no group'})` : 'Off'],
+ ['gRPC proto file', uploadProto && protoFile ? protoFile.name : '—'],
+ ]
+
return (
-
+
Add API
-
- Define a new API and its default upstream servers
-
+
Step {step + 1} of {STEPS.length}: {STEPS[step].hint}
-
-
-
-
- Back to APIs
-
+
Cancel
- {error && (
-
- )}
-
-